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,1140 @@
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
+ DetectorResult - Unified bulk numpy-based result container for detectors.
36
+
37
+ This module provides the DetectorResult class which is the primary return type
38
+ for all detectors and simulations. It replaces both List[DetectionEvent] and
39
+ RecordedRays with a unified interface.
40
+
41
+ Examples
42
+ --------
43
+ >>> from lsurf.detectors import DetectorResult
44
+ >>>
45
+ >>> # Create a result from detection
46
+ >>> result = detector.detect(rays)
47
+ >>> print(f"Detected {result.num_rays} rays")
48
+ >>> print(f"Total intensity: {result.total_intensity:.3e}")
49
+ >>>
50
+ >>> # Filter by wavelength
51
+ >>> visible = result.filter_by_wavelength(400e-9, 700e-9)
52
+ >>>
53
+ >>> # Compute statistics
54
+ >>> stats = result.compute_statistics()
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ from dataclasses import dataclass, field
60
+ from datetime import datetime
61
+ from pathlib import Path
62
+ from typing import TYPE_CHECKING, Any
63
+
64
+ import numpy as np
65
+ from numpy.typing import NDArray
66
+
67
+ if TYPE_CHECKING:
68
+ from ..utilities.recording_sphere import RecordedRays
69
+
70
+ # Optional h5py import
71
+ try:
72
+ import h5py
73
+
74
+ HAS_H5PY = True
75
+ except ImportError:
76
+ HAS_H5PY = False
77
+
78
+ # Import EARTH_RADIUS from surfaces to avoid circular imports at module level
79
+ # Use lazy import in methods that need it
80
+
81
+
82
+ @dataclass
83
+ class DetectorResult:
84
+ """
85
+ Unified bulk numpy-based result container for detector outputs.
86
+
87
+ This is the primary return type for all detectors and simulations,
88
+ providing efficient bulk storage and analysis of detected rays.
89
+
90
+ Attributes
91
+ ----------
92
+ positions : ndarray, shape (N, 3)
93
+ Intersection positions (meters)
94
+ directions : ndarray, shape (N, 3)
95
+ Ray directions at intersection (unit vectors)
96
+ times : ndarray, shape (N,)
97
+ Time of arrival (seconds)
98
+ intensities : ndarray, shape (N,)
99
+ Ray intensity at detection
100
+ wavelengths : ndarray, shape (N,)
101
+ Ray wavelength (meters)
102
+ ray_indices : ndarray, shape (N,), optional
103
+ Original ray indices in the source RayBatch
104
+ generations : ndarray, shape (N,), optional
105
+ Ray generation (number of surface interactions)
106
+ polarization_vectors : ndarray, shape (N, 3), optional
107
+ 3D polarization vectors (electric field direction)
108
+ detector_name : str
109
+ Name of the detector that produced this result
110
+ metadata : dict
111
+ Additional metadata (simulation parameters, etc.)
112
+
113
+ Examples
114
+ --------
115
+ >>> result = detector.detect(rays)
116
+ >>> print(f"Detected {result.num_rays} rays")
117
+ >>> print(f"Total intensity: {result.total_intensity:.3e}")
118
+ >>>
119
+ >>> # Filter by time window
120
+ >>> early = result.filter_by_time(0, 1e-6)
121
+ >>>
122
+ >>> # Merge multiple results
123
+ >>> combined = DetectorResult.merge([result1, result2])
124
+ """
125
+
126
+ # Core data (always present)
127
+ positions: NDArray[np.float32]
128
+ directions: NDArray[np.float32]
129
+ times: NDArray[np.float32]
130
+ intensities: NDArray[np.float32]
131
+ wavelengths: NDArray[np.float32]
132
+
133
+ # Optional data
134
+ ray_indices: NDArray[np.int32] | None = None
135
+ generations: NDArray[np.int32] | None = None
136
+ polarization_vectors: NDArray[np.float32] | None = None
137
+
138
+ # Metadata
139
+ detector_name: str = "unnamed"
140
+ metadata: dict[str, Any] = field(default_factory=dict)
141
+
142
+ # -------------------------------------------------------------------------
143
+ # Properties
144
+ # -------------------------------------------------------------------------
145
+
146
+ @property
147
+ def num_rays(self) -> int:
148
+ """Number of detected rays."""
149
+ return len(self.positions)
150
+
151
+ @property
152
+ def total_intensity(self) -> float:
153
+ """Sum of all detected intensities."""
154
+ return float(np.sum(self.intensities))
155
+
156
+ @property
157
+ def is_empty(self) -> bool:
158
+ """Whether the result contains no rays."""
159
+ return self.num_rays == 0
160
+
161
+ # -------------------------------------------------------------------------
162
+ # Statistics and Analysis
163
+ # -------------------------------------------------------------------------
164
+
165
+ def compute_statistics(self) -> dict[str, Any]:
166
+ """
167
+ Compute summary statistics for the detected rays.
168
+
169
+ Returns
170
+ -------
171
+ dict
172
+ Dictionary containing:
173
+ - count: number of rays
174
+ - total_intensity: sum of intensities
175
+ - mean_time: average arrival time
176
+ - std_time: arrival time standard deviation
177
+ - min_time: earliest arrival
178
+ - max_time: latest arrival
179
+ - mean_wavelength: average wavelength
180
+ - time_spread: max_time - min_time
181
+
182
+ Examples
183
+ --------
184
+ >>> stats = result.compute_statistics()
185
+ >>> print(f"Detected {stats['count']} rays")
186
+ >>> print(f"Time spread: {stats['time_spread']:.3e} s")
187
+ """
188
+ if self.is_empty:
189
+ return {
190
+ "count": 0,
191
+ "total_intensity": 0.0,
192
+ "mean_time": 0.0,
193
+ "std_time": 0.0,
194
+ "min_time": 0.0,
195
+ "max_time": 0.0,
196
+ "mean_wavelength": 0.0,
197
+ "time_spread": 0.0,
198
+ }
199
+
200
+ return {
201
+ "count": self.num_rays,
202
+ "total_intensity": self.total_intensity,
203
+ "mean_time": float(np.mean(self.times)),
204
+ "std_time": float(np.std(self.times)),
205
+ "min_time": float(np.min(self.times)),
206
+ "max_time": float(np.max(self.times)),
207
+ "mean_wavelength": float(np.mean(self.wavelengths)),
208
+ "time_spread": float(np.max(self.times) - np.min(self.times)),
209
+ }
210
+
211
+ def compute_time_histogram(
212
+ self,
213
+ num_bins: int = 50,
214
+ time_range: tuple[float, float] | None = None,
215
+ weighted: bool = True,
216
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
217
+ """
218
+ Compute arrival time distribution histogram.
219
+
220
+ Parameters
221
+ ----------
222
+ num_bins : int
223
+ Number of histogram bins
224
+ time_range : tuple, optional
225
+ (min, max) time range. If None, uses data range.
226
+ weighted : bool
227
+ If True, weight by intensity. If False, count rays.
228
+
229
+ Returns
230
+ -------
231
+ bin_centers : ndarray
232
+ Bin centers in seconds
233
+ values : ndarray
234
+ Histogram values (counts or intensity sum per bin)
235
+
236
+ Examples
237
+ --------
238
+ >>> times, counts = result.compute_time_histogram(num_bins=100)
239
+ >>> plt.bar(times * 1e9, counts, width=(times[1]-times[0])*1e9)
240
+ >>> plt.xlabel('Time (ns)')
241
+ """
242
+ if self.is_empty:
243
+ return np.array([], dtype=np.float64), np.array([], dtype=np.float64)
244
+
245
+ if time_range is None:
246
+ time_range = (float(np.min(self.times)), float(np.max(self.times)))
247
+
248
+ # Handle case where all times are very similar
249
+ time_span = time_range[1] - time_range[0]
250
+ if time_span < 1e-12:
251
+ weights = self.intensities if weighted else None
252
+ total = np.sum(weights) if weights is not None else self.num_rays
253
+ return np.array([np.mean(self.times)]), np.array([total])
254
+
255
+ weights = self.intensities if weighted else None
256
+ values, bin_edges = np.histogram(
257
+ self.times, bins=num_bins, range=time_range, weights=weights
258
+ )
259
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
260
+
261
+ return bin_centers, values.astype(np.float64)
262
+
263
+ def compute_angular_distribution(
264
+ self,
265
+ reference_direction: NDArray[np.float32],
266
+ num_bins: int = 50,
267
+ weighted: bool = True,
268
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
269
+ """
270
+ Compute angular distribution histogram.
271
+
272
+ Parameters
273
+ ----------
274
+ reference_direction : ndarray, shape (3,)
275
+ Reference direction for angle calculation
276
+ num_bins : int
277
+ Number of histogram bins
278
+ weighted : bool
279
+ If True, weight by intensity. If False, count rays.
280
+
281
+ Returns
282
+ -------
283
+ bin_centers : ndarray
284
+ Bin centers in degrees (0-180)
285
+ values : ndarray
286
+ Histogram values
287
+
288
+ Examples
289
+ --------
290
+ >>> angles, counts = result.compute_angular_distribution(
291
+ ... reference_direction=np.array([0, 0, 1])
292
+ ... )
293
+ """
294
+ if self.is_empty:
295
+ return np.array([], dtype=np.float64), np.array([], dtype=np.float64)
296
+
297
+ ref = reference_direction / np.linalg.norm(reference_direction)
298
+ dir_norms = self.directions / np.linalg.norm(
299
+ self.directions, axis=1, keepdims=True
300
+ )
301
+ cos_angles = np.dot(dir_norms, ref)
302
+ cos_angles = np.clip(cos_angles, -1.0, 1.0)
303
+ angles = np.degrees(np.arccos(cos_angles))
304
+
305
+ weights = self.intensities if weighted else None
306
+ values, bin_edges = np.histogram(
307
+ angles, bins=num_bins, range=(0, 180), weights=weights
308
+ )
309
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
310
+
311
+ return bin_centers, values.astype(np.float64)
312
+
313
+ def compute_angular_coordinates(
314
+ self,
315
+ earth_center: NDArray[np.float64] | None = None,
316
+ ) -> dict[str, NDArray[np.float32]]:
317
+ """
318
+ Compute angular coordinates for all ray intersection points.
319
+
320
+ Computes spherical coordinates (latitude/longitude) of intersection points
321
+ on a detection sphere relative to Earth's center.
322
+
323
+ Parameters
324
+ ----------
325
+ earth_center : ndarray, optional
326
+ Earth center position, default (0, 0, -EARTH_RADIUS)
327
+
328
+ Returns
329
+ -------
330
+ dict
331
+ Dictionary with:
332
+ - 'elevation': Latitude angle above equator (radians, -π/2 to π/2)
333
+ - 'azimuth': Longitude angle (radians, -π to π)
334
+ - 'zenith': Zenith angle from north pole (radians, 0 to π)
335
+ - 'incidence': Angle between ray direction and outward radial (radians)
336
+
337
+ Examples
338
+ --------
339
+ >>> coords = result.compute_angular_coordinates()
340
+ >>> elevation_deg = np.degrees(coords['elevation'])
341
+ """
342
+ from ..surfaces import EARTH_RADIUS
343
+
344
+ if self.is_empty:
345
+ return {
346
+ "elevation": np.array([], dtype=np.float32),
347
+ "azimuth": np.array([], dtype=np.float32),
348
+ "zenith": np.array([], dtype=np.float32),
349
+ "incidence": np.array([], dtype=np.float32),
350
+ }
351
+
352
+ if earth_center is None:
353
+ earth_center = np.array([0, 0, -EARTH_RADIUS], dtype=np.float64)
354
+
355
+ # Vector from Earth center to intersection point
356
+ to_pos = self.positions.astype(np.float64) - earth_center
357
+ r = np.linalg.norm(to_pos, axis=1, keepdims=True)
358
+
359
+ # Spherical coordinates of the intersection point
360
+ elevation = np.arcsin(to_pos[:, 2] / r.squeeze())
361
+ _azimuth = np.arctan2(to_pos[:, 1], to_pos[:, 0]) # noqa: F841
362
+
363
+ # Incidence angle: angle between ray direction and outward radial
364
+ radial = to_pos / r
365
+ cos_incidence = np.sum(self.directions * radial, axis=1)
366
+ incidence = np.arccos(np.clip(cos_incidence, -1.0, 1.0))
367
+
368
+ # For azimuth of direction, project onto local tangent plane
369
+ global_z = np.array([0, 0, 1], dtype=np.float64)
370
+ tangent_x = np.cross(global_z, radial)
371
+ tangent_x_norm = np.linalg.norm(tangent_x, axis=1, keepdims=True)
372
+ tangent_x = tangent_x / np.maximum(tangent_x_norm, 1e-10)
373
+ tangent_y = np.cross(radial, tangent_x)
374
+
375
+ dir_x = np.sum(self.directions * tangent_x, axis=1)
376
+ dir_y = np.sum(self.directions * tangent_y, axis=1)
377
+ dir_azimuth = np.arctan2(dir_y, dir_x)
378
+
379
+ return {
380
+ "elevation": elevation.astype(np.float32),
381
+ "azimuth": dir_azimuth.astype(np.float32),
382
+ "zenith": incidence.astype(np.float32),
383
+ "incidence": incidence.astype(np.float32),
384
+ }
385
+
386
+ def compute_viewing_angle_from_origin(
387
+ self,
388
+ origin: NDArray[np.float64] | None = None,
389
+ ) -> NDArray[np.float32]:
390
+ """
391
+ Compute viewing angle from horizontal at specified origin.
392
+
393
+ Calculates the angle above the horizontal plane (XY plane) when
394
+ viewing each intersection point from the origin position.
395
+
396
+ Parameters
397
+ ----------
398
+ origin : ndarray, optional
399
+ Observer position, default (0, 0, 0)
400
+
401
+ Returns
402
+ -------
403
+ ndarray
404
+ Viewing angle from horizontal in radians (-π/2 to π/2)
405
+ Positive angles are above horizontal, negative below
406
+
407
+ Examples
408
+ --------
409
+ >>> viewing_angles = result.compute_viewing_angle_from_origin()
410
+ >>> print(f"Mean viewing angle: {np.degrees(viewing_angles.mean()):.1f}°")
411
+ """
412
+ if self.is_empty:
413
+ return np.array([], dtype=np.float32)
414
+
415
+ if origin is None:
416
+ origin = np.array([0, 0, 0], dtype=np.float64)
417
+
418
+ to_point = self.positions.astype(np.float64) - origin
419
+ horiz_dist = np.sqrt(to_point[:, 0] ** 2 + to_point[:, 1] ** 2)
420
+ vert_dist = to_point[:, 2]
421
+ viewing_angle = np.arctan2(vert_dist, horiz_dist)
422
+
423
+ return viewing_angle.astype(np.float32)
424
+
425
+ def compute_ray_direction_angles(self) -> dict[str, NDArray[np.float32]]:
426
+ """
427
+ Compute elevation and azimuth angles of ray directions.
428
+
429
+ Returns
430
+ -------
431
+ dict
432
+ Dictionary with:
433
+ - 'elevation': Angle above horizontal plane in radians (-π/2 to π/2)
434
+ - 'azimuth': Azimuth angle in horizontal plane in radians (-π to π)
435
+
436
+ Examples
437
+ --------
438
+ >>> angles = result.compute_ray_direction_angles()
439
+ >>> print(f"Mean elevation: {np.degrees(angles['elevation'].mean()):.1f}°")
440
+ """
441
+ if self.is_empty:
442
+ return {
443
+ "elevation": np.array([], dtype=np.float32),
444
+ "azimuth": np.array([], dtype=np.float32),
445
+ }
446
+
447
+ elevation = np.arcsin(np.clip(self.directions[:, 2], -1.0, 1.0))
448
+ azimuth = np.arctan2(self.directions[:, 1], self.directions[:, 0])
449
+
450
+ return {
451
+ "elevation": elevation.astype(np.float32),
452
+ "azimuth": azimuth.astype(np.float32),
453
+ }
454
+
455
+ # -------------------------------------------------------------------------
456
+ # Filtering
457
+ # -------------------------------------------------------------------------
458
+
459
+ def filter(self, mask: NDArray[np.bool_]) -> "DetectorResult":
460
+ """
461
+ Filter rays by boolean mask.
462
+
463
+ Parameters
464
+ ----------
465
+ mask : ndarray of bool
466
+ Boolean mask, True for rays to keep
467
+
468
+ Returns
469
+ -------
470
+ DetectorResult
471
+ Filtered result containing only selected rays
472
+
473
+ Examples
474
+ --------
475
+ >>> high_intensity = result.filter(result.intensities > 0.1)
476
+ """
477
+ return DetectorResult(
478
+ positions=self.positions[mask],
479
+ directions=self.directions[mask],
480
+ times=self.times[mask],
481
+ intensities=self.intensities[mask],
482
+ wavelengths=self.wavelengths[mask],
483
+ ray_indices=(
484
+ self.ray_indices[mask] if self.ray_indices is not None else None
485
+ ),
486
+ generations=(
487
+ self.generations[mask] if self.generations is not None else None
488
+ ),
489
+ polarization_vectors=(
490
+ self.polarization_vectors[mask]
491
+ if self.polarization_vectors is not None
492
+ else None
493
+ ),
494
+ detector_name=self.detector_name,
495
+ metadata=self.metadata.copy(),
496
+ )
497
+
498
+ def filter_by_wavelength(
499
+ self, min_wavelength: float, max_wavelength: float
500
+ ) -> "DetectorResult":
501
+ """
502
+ Filter rays by wavelength range.
503
+
504
+ Parameters
505
+ ----------
506
+ min_wavelength : float
507
+ Minimum wavelength in meters
508
+ max_wavelength : float
509
+ Maximum wavelength in meters
510
+
511
+ Returns
512
+ -------
513
+ DetectorResult
514
+ Filtered result
515
+
516
+ Examples
517
+ --------
518
+ >>> visible = result.filter_by_wavelength(400e-9, 700e-9)
519
+ """
520
+ mask = (self.wavelengths >= min_wavelength) & (
521
+ self.wavelengths <= max_wavelength
522
+ )
523
+ return self.filter(mask)
524
+
525
+ def filter_by_time(self, min_time: float, max_time: float) -> "DetectorResult":
526
+ """
527
+ Filter rays by time range.
528
+
529
+ Parameters
530
+ ----------
531
+ min_time : float
532
+ Minimum time in seconds
533
+ max_time : float
534
+ Maximum time in seconds
535
+
536
+ Returns
537
+ -------
538
+ DetectorResult
539
+ Filtered result
540
+
541
+ Examples
542
+ --------
543
+ >>> early_arrivals = result.filter_by_time(0, 1e-6)
544
+ """
545
+ mask = (self.times >= min_time) & (self.times <= max_time)
546
+ return self.filter(mask)
547
+
548
+ def filter_by_intensity(
549
+ self, min_intensity: float = 0.0, max_intensity: float = float("inf")
550
+ ) -> "DetectorResult":
551
+ """
552
+ Filter rays by intensity range.
553
+
554
+ Parameters
555
+ ----------
556
+ min_intensity : float
557
+ Minimum intensity
558
+ max_intensity : float
559
+ Maximum intensity
560
+
561
+ Returns
562
+ -------
563
+ DetectorResult
564
+ Filtered result
565
+
566
+ Examples
567
+ --------
568
+ >>> bright = result.filter_by_intensity(min_intensity=0.1)
569
+ """
570
+ mask = (self.intensities >= min_intensity) & (self.intensities <= max_intensity)
571
+ return self.filter(mask)
572
+
573
+ # -------------------------------------------------------------------------
574
+ # Class Methods
575
+ # -------------------------------------------------------------------------
576
+
577
+ @classmethod
578
+ def empty(cls, detector_name: str = "unnamed") -> "DetectorResult":
579
+ """
580
+ Create an empty DetectorResult.
581
+
582
+ Parameters
583
+ ----------
584
+ detector_name : str
585
+ Name for the detector
586
+
587
+ Returns
588
+ -------
589
+ DetectorResult
590
+ Empty result with zero rays
591
+
592
+ Examples
593
+ --------
594
+ >>> empty = DetectorResult.empty("my_detector")
595
+ >>> print(empty.is_empty) # True
596
+ """
597
+ return cls(
598
+ positions=np.zeros((0, 3), dtype=np.float32),
599
+ directions=np.zeros((0, 3), dtype=np.float32),
600
+ times=np.zeros(0, dtype=np.float32),
601
+ intensities=np.zeros(0, dtype=np.float32),
602
+ wavelengths=np.zeros(0, dtype=np.float32),
603
+ ray_indices=None,
604
+ generations=None,
605
+ polarization_vectors=None,
606
+ detector_name=detector_name,
607
+ )
608
+
609
+ @classmethod
610
+ def merge(cls, results: list["DetectorResult"]) -> "DetectorResult":
611
+ """
612
+ Merge multiple DetectorResults into one.
613
+
614
+ Parameters
615
+ ----------
616
+ results : list of DetectorResult
617
+ Results to merge
618
+
619
+ Returns
620
+ -------
621
+ DetectorResult
622
+ Combined result
623
+
624
+ Examples
625
+ --------
626
+ >>> combined = DetectorResult.merge([result1, result2, result3])
627
+ """
628
+ if not results:
629
+ return cls.empty()
630
+
631
+ non_empty = [r for r in results if not r.is_empty]
632
+ if not non_empty:
633
+ return cls.empty(results[0].detector_name if results else "unnamed")
634
+
635
+ if len(non_empty) == 1:
636
+ return non_empty[0]
637
+
638
+ # Check for optional fields
639
+ has_ray_indices = all(r.ray_indices is not None for r in non_empty)
640
+ has_generations = all(r.generations is not None for r in non_empty)
641
+ has_polarization = all(r.polarization_vectors is not None for r in non_empty)
642
+
643
+ return cls(
644
+ positions=np.vstack([r.positions for r in non_empty]),
645
+ directions=np.vstack([r.directions for r in non_empty]),
646
+ times=np.concatenate([r.times for r in non_empty]),
647
+ intensities=np.concatenate([r.intensities for r in non_empty]),
648
+ wavelengths=np.concatenate([r.wavelengths for r in non_empty]),
649
+ ray_indices=(
650
+ np.concatenate([r.ray_indices for r in non_empty])
651
+ if has_ray_indices
652
+ else None
653
+ ),
654
+ generations=(
655
+ np.concatenate([r.generations for r in non_empty])
656
+ if has_generations
657
+ else None
658
+ ),
659
+ polarization_vectors=(
660
+ np.vstack([r.polarization_vectors for r in non_empty])
661
+ if has_polarization
662
+ else None
663
+ ),
664
+ detector_name=non_empty[0].detector_name,
665
+ metadata=non_empty[0].metadata.copy(),
666
+ )
667
+
668
+ # -------------------------------------------------------------------------
669
+ # Serialization
670
+ # -------------------------------------------------------------------------
671
+
672
+ def save_npz(self, filepath: str | Path) -> None:
673
+ """
674
+ Save to numpy .npz file.
675
+
676
+ Parameters
677
+ ----------
678
+ filepath : str or Path
679
+ Output file path
680
+
681
+ Examples
682
+ --------
683
+ >>> result.save_npz("detections.npz")
684
+ """
685
+ filepath = Path(filepath)
686
+ filepath.parent.mkdir(parents=True, exist_ok=True)
687
+
688
+ # Compute angular coordinates for convenience
689
+ angular = self.compute_angular_coordinates()
690
+
691
+ save_dict = {
692
+ "positions": self.positions,
693
+ "directions": self.directions,
694
+ "times": self.times,
695
+ "intensities": self.intensities,
696
+ "wavelengths": self.wavelengths,
697
+ "elevation": angular["elevation"],
698
+ "azimuth": angular["azimuth"],
699
+ "zenith": angular["zenith"],
700
+ }
701
+
702
+ if self.ray_indices is not None:
703
+ save_dict["ray_indices"] = self.ray_indices
704
+ if self.generations is not None:
705
+ save_dict["generations"] = self.generations
706
+ if self.polarization_vectors is not None:
707
+ save_dict["polarization_vectors"] = self.polarization_vectors
708
+
709
+ # Add metadata
710
+ save_dict["meta_detector_name"] = np.array(self.detector_name)
711
+ for key, value in self.metadata.items():
712
+ save_dict[f"meta_{key}"] = np.array(value)
713
+
714
+ np.savez_compressed(filepath, **save_dict)
715
+
716
+ @classmethod
717
+ def load_npz(cls, filepath: str | Path) -> "DetectorResult":
718
+ """
719
+ Load from numpy .npz file.
720
+
721
+ Parameters
722
+ ----------
723
+ filepath : str or Path
724
+ Input file path
725
+
726
+ Returns
727
+ -------
728
+ DetectorResult
729
+ Loaded result
730
+
731
+ Examples
732
+ --------
733
+ >>> result = DetectorResult.load_npz("detections.npz")
734
+ """
735
+ data = np.load(filepath, allow_pickle=True)
736
+
737
+ # Extract metadata
738
+ metadata = {}
739
+ detector_name = "unnamed"
740
+ for key in data.files:
741
+ if key.startswith("meta_"):
742
+ meta_key = key[5:]
743
+ if meta_key == "detector_name":
744
+ detector_name = str(data[key])
745
+ else:
746
+ metadata[meta_key] = data[key]
747
+
748
+ return cls(
749
+ positions=data["positions"],
750
+ directions=data["directions"],
751
+ times=data["times"],
752
+ intensities=data["intensities"],
753
+ wavelengths=data["wavelengths"],
754
+ ray_indices=data.get("ray_indices"),
755
+ generations=data.get("generations"),
756
+ polarization_vectors=data.get("polarization_vectors"),
757
+ detector_name=detector_name,
758
+ metadata=metadata,
759
+ )
760
+
761
+ def save_hdf5(
762
+ self,
763
+ filepath: str | Path,
764
+ compression: str = "gzip",
765
+ ) -> None:
766
+ """
767
+ Save to HDF5 file.
768
+
769
+ Parameters
770
+ ----------
771
+ filepath : str or Path
772
+ Output file path
773
+ compression : str
774
+ Compression algorithm ('gzip', 'lzf', or None)
775
+
776
+ Examples
777
+ --------
778
+ >>> result.save_hdf5("detections.h5")
779
+ """
780
+ if not HAS_H5PY:
781
+ raise ImportError(
782
+ "h5py is required for HDF5 support. Install with: pip install h5py"
783
+ )
784
+
785
+ filepath = Path(filepath)
786
+ filepath.parent.mkdir(parents=True, exist_ok=True)
787
+
788
+ with h5py.File(filepath, "w") as f:
789
+ # Create rays group
790
+ rays_grp = f.create_group("rays")
791
+
792
+ # Store ray data with compression
793
+ rays_grp.create_dataset(
794
+ "positions", data=self.positions, compression=compression
795
+ )
796
+ rays_grp.create_dataset(
797
+ "directions", data=self.directions, compression=compression
798
+ )
799
+ rays_grp.create_dataset("times", data=self.times, compression=compression)
800
+ rays_grp.create_dataset(
801
+ "intensities", data=self.intensities, compression=compression
802
+ )
803
+ rays_grp.create_dataset(
804
+ "wavelengths", data=self.wavelengths, compression=compression
805
+ )
806
+
807
+ # Optional fields
808
+ if self.ray_indices is not None:
809
+ rays_grp.create_dataset(
810
+ "ray_indices", data=self.ray_indices, compression=compression
811
+ )
812
+ if self.generations is not None:
813
+ rays_grp.create_dataset(
814
+ "generations", data=self.generations, compression=compression
815
+ )
816
+ if self.polarization_vectors is not None:
817
+ rays_grp.create_dataset(
818
+ "polarization_vectors",
819
+ data=self.polarization_vectors,
820
+ compression=compression,
821
+ )
822
+
823
+ # Compute and store angular coordinates
824
+ angular = self.compute_angular_coordinates()
825
+ angular_grp = f.create_group("angular")
826
+ for key, value in angular.items():
827
+ angular_grp.create_dataset(key, data=value, compression=compression)
828
+
829
+ # Store metadata
830
+ meta_grp = f.create_group("metadata")
831
+ meta_grp.attrs["num_rays"] = self.num_rays
832
+ meta_grp.attrs["detector_name"] = self.detector_name
833
+ meta_grp.attrs["save_time"] = datetime.now().isoformat()
834
+
835
+ for key, value in self.metadata.items():
836
+ if isinstance(value, (int, float, str, bool)):
837
+ meta_grp.attrs[key] = value
838
+ elif isinstance(value, np.ndarray):
839
+ meta_grp.create_dataset(key, data=value)
840
+ elif isinstance(value, (list, tuple)):
841
+ meta_grp.create_dataset(key, data=np.array(value))
842
+ else:
843
+ meta_grp.attrs[key] = str(value)
844
+
845
+ @classmethod
846
+ def load_hdf5(cls, filepath: str | Path) -> "DetectorResult":
847
+ """
848
+ Load from HDF5 file.
849
+
850
+ Parameters
851
+ ----------
852
+ filepath : str or Path
853
+ Input file path
854
+
855
+ Returns
856
+ -------
857
+ DetectorResult
858
+ Loaded result
859
+
860
+ Examples
861
+ --------
862
+ >>> result = DetectorResult.load_hdf5("detections.h5")
863
+ """
864
+ if not HAS_H5PY:
865
+ raise ImportError(
866
+ "h5py is required for HDF5 support. Install with: pip install h5py"
867
+ )
868
+
869
+ with h5py.File(filepath, "r") as f:
870
+ rays_grp = f["rays"]
871
+
872
+ # Load required fields
873
+ positions = rays_grp["positions"][...]
874
+ directions = rays_grp["directions"][...]
875
+ times = rays_grp["times"][...]
876
+ intensities = rays_grp["intensities"][...]
877
+ wavelengths = rays_grp["wavelengths"][...]
878
+
879
+ # Load optional fields
880
+ ray_indices = (
881
+ rays_grp["ray_indices"][...] if "ray_indices" in rays_grp else None
882
+ )
883
+ generations = (
884
+ rays_grp["generations"][...] if "generations" in rays_grp else None
885
+ )
886
+ polarization_vectors = (
887
+ rays_grp["polarization_vectors"][...]
888
+ if "polarization_vectors" in rays_grp
889
+ else None
890
+ )
891
+
892
+ # Load metadata
893
+ metadata = {}
894
+ detector_name = "unnamed"
895
+ if "metadata" in f:
896
+ meta_grp = f["metadata"]
897
+ detector_name = meta_grp.attrs.get("detector_name", "unnamed")
898
+ for key, value in meta_grp.attrs.items():
899
+ if key not in ("num_rays", "detector_name", "save_time"):
900
+ metadata[key] = value
901
+ for key in meta_grp.keys():
902
+ metadata[key] = meta_grp[key][...]
903
+
904
+ return cls(
905
+ positions=positions,
906
+ directions=directions,
907
+ times=times,
908
+ intensities=intensities,
909
+ wavelengths=wavelengths,
910
+ ray_indices=ray_indices,
911
+ generations=generations,
912
+ polarization_vectors=polarization_vectors,
913
+ detector_name=detector_name,
914
+ metadata=metadata,
915
+ )
916
+
917
+ # -------------------------------------------------------------------------
918
+ # Backward Compatibility
919
+ # -------------------------------------------------------------------------
920
+
921
+ def to_detection_events(self) -> list:
922
+ """
923
+ Convert to list of DetectionEvent objects for backward compatibility.
924
+
925
+ Returns
926
+ -------
927
+ list of DetectionEvent
928
+ List of individual detection events
929
+
930
+ Examples
931
+ --------
932
+ >>> events = result.to_detection_events()
933
+ >>> for event in events:
934
+ ... print(f"Ray {event.ray_index}: {event.intensity:.3f}")
935
+ """
936
+ from .base import DetectionEvent
937
+
938
+ events = []
939
+ for i in range(self.num_rays):
940
+ ray_idx = int(self.ray_indices[i]) if self.ray_indices is not None else i
941
+ event = DetectionEvent(
942
+ ray_index=ray_idx,
943
+ position=self.positions[i].copy(),
944
+ direction=self.directions[i].copy(),
945
+ time=float(self.times[i]),
946
+ wavelength=float(self.wavelengths[i]),
947
+ intensity=float(self.intensities[i]),
948
+ )
949
+ events.append(event)
950
+ return events
951
+
952
+ @classmethod
953
+ def from_detection_events(
954
+ cls,
955
+ events: list,
956
+ detector_name: str = "unnamed",
957
+ generations: NDArray[np.int32] | None = None,
958
+ polarization_vectors: NDArray[np.float32] | None = None,
959
+ ) -> "DetectorResult":
960
+ """
961
+ Create from list of DetectionEvent objects for backward compatibility.
962
+
963
+ Parameters
964
+ ----------
965
+ events : list of DetectionEvent
966
+ List of detection events
967
+ detector_name : str
968
+ Detector name
969
+ generations : ndarray, optional
970
+ Ray generations if available
971
+ polarization_vectors : ndarray, optional
972
+ Polarization vectors if available
973
+
974
+ Returns
975
+ -------
976
+ DetectorResult
977
+ Converted result
978
+
979
+ Examples
980
+ --------
981
+ >>> result = DetectorResult.from_detection_events(detector.events)
982
+ """
983
+ if not events:
984
+ return cls.empty(detector_name)
985
+
986
+ return cls(
987
+ positions=np.stack([e.position for e in events]).astype(np.float32),
988
+ directions=np.stack([e.direction for e in events]).astype(np.float32),
989
+ times=np.array([e.time for e in events], dtype=np.float32),
990
+ intensities=np.array([e.intensity for e in events], dtype=np.float32),
991
+ wavelengths=np.array([e.wavelength for e in events], dtype=np.float32),
992
+ ray_indices=np.array([e.ray_index for e in events], dtype=np.int32),
993
+ generations=generations,
994
+ polarization_vectors=polarization_vectors,
995
+ detector_name=detector_name,
996
+ )
997
+
998
+ def to_recorded_rays(self) -> "RecordedRays":
999
+ """
1000
+ Convert to RecordedRays for backward compatibility.
1001
+
1002
+ Returns
1003
+ -------
1004
+ RecordedRays
1005
+ Converted RecordedRays object
1006
+
1007
+ Notes
1008
+ -----
1009
+ This is provided for backward compatibility during migration.
1010
+ New code should use DetectorResult directly.
1011
+ """
1012
+ from ..utilities.recording_sphere import RecordedRays
1013
+
1014
+ return RecordedRays(
1015
+ positions=self.positions,
1016
+ directions=self.directions,
1017
+ times=self.times,
1018
+ intensities=self.intensities,
1019
+ wavelengths=self.wavelengths,
1020
+ generations=(
1021
+ self.generations
1022
+ if self.generations is not None
1023
+ else np.zeros(self.num_rays, dtype=np.int32)
1024
+ ),
1025
+ polarization_vectors=self.polarization_vectors,
1026
+ )
1027
+
1028
+ @classmethod
1029
+ def from_recorded_rays(
1030
+ cls,
1031
+ recorded: "RecordedRays",
1032
+ detector_name: str = "unnamed",
1033
+ ray_indices: NDArray[np.int32] | None = None,
1034
+ ) -> "DetectorResult":
1035
+ """
1036
+ Create from RecordedRays for backward compatibility.
1037
+
1038
+ Parameters
1039
+ ----------
1040
+ recorded : RecordedRays
1041
+ RecordedRays object to convert
1042
+ detector_name : str
1043
+ Detector name
1044
+ ray_indices : ndarray, optional
1045
+ Original ray indices if available
1046
+
1047
+ Returns
1048
+ -------
1049
+ DetectorResult
1050
+ Converted result
1051
+
1052
+ Notes
1053
+ -----
1054
+ This is provided for backward compatibility during migration.
1055
+ New code should use DetectorResult directly.
1056
+ """
1057
+ # Use recorded.ray_indices if available and ray_indices parameter not provided
1058
+ final_ray_indices = ray_indices
1059
+ if final_ray_indices is None and hasattr(recorded, "ray_indices"):
1060
+ final_ray_indices = recorded.ray_indices
1061
+
1062
+ return cls(
1063
+ positions=recorded.positions,
1064
+ directions=recorded.directions,
1065
+ times=recorded.times,
1066
+ intensities=recorded.intensities,
1067
+ wavelengths=recorded.wavelengths,
1068
+ ray_indices=final_ray_indices,
1069
+ generations=recorded.generations,
1070
+ polarization_vectors=recorded.polarization_vectors,
1071
+ detector_name=detector_name,
1072
+ )
1073
+
1074
+ # -------------------------------------------------------------------------
1075
+ # Representation
1076
+ # -------------------------------------------------------------------------
1077
+
1078
+ def __repr__(self) -> str:
1079
+ """Return string representation."""
1080
+ return (
1081
+ f"DetectorResult(detector='{self.detector_name}', "
1082
+ f"rays={self.num_rays}, intensity={self.total_intensity:.3e})"
1083
+ )
1084
+
1085
+ def __len__(self) -> int:
1086
+ """Return number of detected rays."""
1087
+ return self.num_rays
1088
+
1089
+ def __iter__(self):
1090
+ """
1091
+ Iterate over detection events for backward compatibility.
1092
+
1093
+ Yields DetectionEvent objects for each detected ray.
1094
+ New code should access arrays directly instead.
1095
+ """
1096
+ from .base import DetectionEvent
1097
+
1098
+ for i in range(self.num_rays):
1099
+ ray_idx = int(self.ray_indices[i]) if self.ray_indices is not None else i
1100
+ yield DetectionEvent(
1101
+ ray_index=ray_idx,
1102
+ position=self.positions[i].copy(),
1103
+ direction=self.directions[i].copy(),
1104
+ time=float(self.times[i]),
1105
+ wavelength=float(self.wavelengths[i]),
1106
+ intensity=float(self.intensities[i]),
1107
+ )
1108
+
1109
+ def __getitem__(self, index: int):
1110
+ """
1111
+ Get a single detection event by index for backward compatibility.
1112
+
1113
+ Parameters
1114
+ ----------
1115
+ index : int
1116
+ Index of the detection event
1117
+
1118
+ Returns
1119
+ -------
1120
+ DetectionEvent
1121
+ Detection event at the specified index
1122
+ """
1123
+ from .base import DetectionEvent
1124
+
1125
+ if index < 0:
1126
+ index = self.num_rays + index
1127
+ if index < 0 or index >= self.num_rays:
1128
+ raise IndexError(f"Index {index} out of range for {self.num_rays} rays")
1129
+
1130
+ ray_idx = (
1131
+ int(self.ray_indices[index]) if self.ray_indices is not None else index
1132
+ )
1133
+ return DetectionEvent(
1134
+ ray_index=ray_idx,
1135
+ position=self.positions[index].copy(),
1136
+ direction=self.directions[index].copy(),
1137
+ time=float(self.times[index]),
1138
+ wavelength=float(self.wavelengths[index]),
1139
+ intensity=float(self.intensities[index]),
1140
+ )