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,418 @@
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
+ HEALPix Utilities for Sphere Pattern Analysis
36
+
37
+ Functions for converting recorded ray data to HEALPix representation
38
+ and aggregating statistics per pixel.
39
+ """
40
+
41
+ import numpy as np
42
+ from numpy.typing import NDArray
43
+
44
+ try:
45
+ from astropy import units as u
46
+ from astropy_healpix import HEALPix
47
+
48
+ HAS_HEALPIX = True
49
+ except ImportError:
50
+ HAS_HEALPIX = False
51
+
52
+
53
+ class HEALPixData:
54
+ """
55
+ Container for HEALPix-mapped ray data.
56
+
57
+ Attributes
58
+ ----------
59
+ nside : int
60
+ HEALPix resolution parameter
61
+ npix : int
62
+ Total number of HEALPix pixels
63
+ pixel_indices : ndarray
64
+ HEALPix pixel index for each ray
65
+ lon : ndarray
66
+ Longitude (azimuth) for each ray in radians
67
+ lat : ndarray
68
+ Latitude (elevation) for each ray in radians
69
+ intensities : ndarray
70
+ Intensity for each ray
71
+ times : ndarray
72
+ Arrival time for each ray (seconds)
73
+ generations : ndarray
74
+ Generation (bounce count) for each ray
75
+ viewing_angle : ndarray or None
76
+ Viewing angle from horizontal at (0,0,0) in radians
77
+ ray_elevation : ndarray or None
78
+ Ray direction elevation angle from horizontal in radians
79
+ ray_azimuth : ndarray or None
80
+ Ray direction azimuth angle in radians
81
+ aggregated : dict or None
82
+ Aggregated statistics per pixel (if computed)
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ nside: int,
88
+ pixel_indices: NDArray[np.int64],
89
+ lon: NDArray[np.float64],
90
+ lat: NDArray[np.float64],
91
+ intensities: NDArray[np.float32],
92
+ times: NDArray[np.float32],
93
+ generations: NDArray[np.int32],
94
+ viewing_angle: NDArray[np.float32] | None = None,
95
+ ray_elevation: NDArray[np.float32] | None = None,
96
+ ray_azimuth: NDArray[np.float32] | None = None,
97
+ ):
98
+ self.nside = nside
99
+ self.npix = 12 * nside**2
100
+ self.pixel_indices = pixel_indices
101
+ self.lon = lon
102
+ self.lat = lat
103
+ self.intensities = intensities
104
+ self.times = times
105
+ self.generations = generations
106
+ self.viewing_angle = viewing_angle
107
+ self.ray_elevation = ray_elevation
108
+ self.ray_azimuth = ray_azimuth
109
+ self.aggregated = None
110
+
111
+ @property
112
+ def num_rays(self) -> int:
113
+ """Number of recorded rays."""
114
+ return len(self.pixel_indices)
115
+
116
+
117
+ def rays_to_healpix(
118
+ recorded_rays,
119
+ nside: int = 128,
120
+ ) -> HEALPixData:
121
+ """
122
+ Convert recorded rays to HEALPix pixel representation.
123
+
124
+ Parameters
125
+ ----------
126
+ recorded_rays : RecordedRays
127
+ Recorded ray data from detection sphere
128
+ nside : int, optional
129
+ HEALPix resolution parameter (default: 128)
130
+ Higher values give finer resolution
131
+ nside=64 → ~3,400 pixels
132
+ nside=128 → ~13,000 pixels
133
+ nside=256 → ~50,000 pixels
134
+
135
+ Returns
136
+ -------
137
+ HEALPixData
138
+ HEALPix-mapped ray data
139
+
140
+ Raises
141
+ ------
142
+ ImportError
143
+ If astropy-healpix is not installed
144
+ """
145
+ if not HAS_HEALPIX:
146
+ raise ImportError(
147
+ "astropy-healpix is required. Install with: pip install astropy-healpix"
148
+ )
149
+
150
+ # Compute angular coordinates
151
+ angular = recorded_rays.compute_angular_coordinates()
152
+
153
+ # Extract azimuth (longitude) and elevation (latitude)
154
+ # azimuth: 0 to 2π
155
+ # elevation: -π/2 to π/2 (horizon at 0)
156
+ azimuth = angular["azimuth"] # radians
157
+ elevation = angular["elevation"] # radians
158
+
159
+ # Create HEALPix object
160
+ hp = HEALPix(nside=nside, order="ring", frame=None)
161
+
162
+ # Convert to astropy units
163
+ lon = azimuth * u.rad
164
+ lat = elevation * u.rad
165
+
166
+ # Get HEALPix pixel indices
167
+ pixel_indices = hp.lonlat_to_healpix(lon, lat)
168
+
169
+ # Compute viewing angle from horizontal at (0,0,0)
170
+ viewing_angle = recorded_rays.compute_viewing_angle_from_origin()
171
+
172
+ # Compute ray direction angles
173
+ ray_dir_angles = recorded_rays.compute_ray_direction_angles()
174
+
175
+ return HEALPixData(
176
+ nside=nside,
177
+ pixel_indices=pixel_indices,
178
+ lon=azimuth,
179
+ lat=elevation,
180
+ intensities=recorded_rays.intensities,
181
+ times=recorded_rays.times,
182
+ generations=recorded_rays.generations,
183
+ viewing_angle=viewing_angle,
184
+ ray_elevation=ray_dir_angles["elevation"],
185
+ ray_azimuth=ray_dir_angles["azimuth"],
186
+ )
187
+
188
+
189
+ def aggregate_healpix_data(
190
+ healpix_data: HEALPixData,
191
+ ) -> dict[str, NDArray]:
192
+ """
193
+ Aggregate ray properties per HEALPix pixel.
194
+
195
+ Computes per-pixel statistics:
196
+ - Total intensity (sum)
197
+ - Mean arrival time (intensity-weighted)
198
+ - Ray count
199
+ - Mean generation
200
+ - Intensity standard deviation
201
+ - Time standard deviation
202
+
203
+ Parameters
204
+ ----------
205
+ healpix_data : HEALPixData
206
+ HEALPix-mapped ray data
207
+
208
+ Returns
209
+ -------
210
+ dict
211
+ Dictionary with aggregated arrays for each occupied pixel:
212
+ - 'pixel_ids': Array of pixel indices with data
213
+ - 'intensity_sum': Total intensity per pixel
214
+ - 'intensity_mean': Mean intensity per pixel
215
+ - 'time_weighted_mean': Intensity-weighted mean time
216
+ - 'time_mean': Arithmetic mean time
217
+ - 'time_std': Standard deviation of arrival time
218
+ - 'ray_count': Number of rays per pixel
219
+ - 'generation_mean': Mean generation per pixel
220
+ """
221
+ # Get unique pixel indices
222
+ unique_pixels = np.unique(healpix_data.pixel_indices)
223
+ n_pixels = len(unique_pixels)
224
+
225
+ # Initialize aggregated arrays
226
+ intensity_sum = np.zeros(n_pixels, dtype=np.float64)
227
+ intensity_mean = np.zeros(n_pixels, dtype=np.float64)
228
+ time_weighted_mean = np.zeros(n_pixels, dtype=np.float64)
229
+ time_mean = np.zeros(n_pixels, dtype=np.float64)
230
+ time_std = np.zeros(n_pixels, dtype=np.float64)
231
+ ray_count = np.zeros(n_pixels, dtype=np.int32)
232
+ generation_mean = np.zeros(n_pixels, dtype=np.float64)
233
+
234
+ # Aggregate per pixel
235
+ for i, pixel_id in enumerate(unique_pixels):
236
+ mask = healpix_data.pixel_indices == pixel_id
237
+
238
+ # Intensities in this pixel
239
+ pixel_intensities = healpix_data.intensities[mask]
240
+ pixel_times = healpix_data.times[mask]
241
+ pixel_generations = healpix_data.generations[mask]
242
+
243
+ # Sum and count
244
+ intensity_sum[i] = np.sum(pixel_intensities)
245
+ ray_count[i] = np.sum(mask)
246
+ intensity_mean[i] = np.mean(pixel_intensities)
247
+
248
+ # Intensity-weighted mean time
249
+ total_intensity = intensity_sum[i]
250
+ if total_intensity > 0:
251
+ time_weighted_mean[i] = (
252
+ np.sum(pixel_times * pixel_intensities) / total_intensity
253
+ )
254
+ else:
255
+ time_weighted_mean[i] = np.mean(pixel_times)
256
+
257
+ # Arithmetic mean time and std
258
+ time_mean[i] = np.mean(pixel_times)
259
+ time_std[i] = np.std(pixel_times) if len(pixel_times) > 1 else 0.0
260
+
261
+ # Mean generation
262
+ generation_mean[i] = np.mean(pixel_generations)
263
+
264
+ aggregated = {
265
+ "pixel_ids": unique_pixels,
266
+ "intensity_sum": intensity_sum,
267
+ "intensity_mean": intensity_mean,
268
+ "time_weighted_mean": time_weighted_mean,
269
+ "time_mean": time_mean,
270
+ "time_std": time_std,
271
+ "ray_count": ray_count,
272
+ "generation_mean": generation_mean,
273
+ }
274
+
275
+ # Store in HEALPixData object
276
+ healpix_data.aggregated = aggregated
277
+
278
+ return aggregated
279
+
280
+
281
+ def identify_peak_region(
282
+ healpix_data: HEALPixData,
283
+ threshold_percentile: float = 90.0,
284
+ ) -> tuple[NDArray[np.int64], NDArray[np.bool_]]:
285
+ """
286
+ Identify pixels in the peak intensity region.
287
+
288
+ Parameters
289
+ ----------
290
+ healpix_data : HEALPixData
291
+ HEALPix data with aggregated statistics
292
+ threshold_percentile : float, optional
293
+ Percentile threshold for peak region (default: 90.0)
294
+ 90.0 means top 10% of pixels by intensity
295
+
296
+ Returns
297
+ -------
298
+ peak_pixel_ids : ndarray
299
+ Pixel IDs in the peak region
300
+ peak_mask : ndarray
301
+ Boolean mask for rays in peak region
302
+
303
+ Raises
304
+ ------
305
+ ValueError
306
+ If aggregated data not computed
307
+ """
308
+ if healpix_data.aggregated is None:
309
+ raise ValueError("Must call aggregate_healpix_data() first")
310
+
311
+ # Get intensity sum per pixel
312
+ intensity_sum = healpix_data.aggregated["intensity_sum"]
313
+ pixel_ids = healpix_data.aggregated["pixel_ids"]
314
+
315
+ # Find threshold
316
+ threshold = np.percentile(intensity_sum, threshold_percentile)
317
+
318
+ # Identify peak pixels
319
+ peak_pixel_mask = intensity_sum >= threshold
320
+ peak_pixel_ids = pixel_ids[peak_pixel_mask]
321
+
322
+ # Create mask for individual rays in peak region
323
+ peak_ray_mask = np.isin(healpix_data.pixel_indices, peak_pixel_ids)
324
+
325
+ return peak_pixel_ids, peak_ray_mask
326
+
327
+
328
+ def compute_time_statistics(
329
+ healpix_data: HEALPixData,
330
+ peak_mask: NDArray[np.bool_] | None = None,
331
+ ) -> dict[str, float]:
332
+ """
333
+ Compute arrival time statistics.
334
+
335
+ Parameters
336
+ ----------
337
+ healpix_data : HEALPixData
338
+ HEALPix-mapped ray data
339
+ peak_mask : ndarray, optional
340
+ Boolean mask to select rays in peak region
341
+ If None, uses all rays
342
+
343
+ Returns
344
+ -------
345
+ dict
346
+ Statistics including:
347
+ - 'mean_time': Mean arrival time (s)
348
+ - 'median_time': Median arrival time (s)
349
+ - 'std_time': Standard deviation (s)
350
+ - 'min_time': Minimum time (s)
351
+ - 'max_time': Maximum time (s)
352
+ - 'time_span': max - min (s)
353
+ - 'weighted_mean_time': Intensity-weighted mean (s)
354
+ - 'fwhm_time': Full width at half maximum (s)
355
+ - 'num_rays': Number of rays included
356
+ """
357
+ if peak_mask is None:
358
+ times = healpix_data.times
359
+ intensities = healpix_data.intensities
360
+ else:
361
+ times = healpix_data.times[peak_mask]
362
+ intensities = healpix_data.intensities[peak_mask]
363
+
364
+ if len(times) == 0:
365
+ return {
366
+ "mean_time": 0.0,
367
+ "median_time": 0.0,
368
+ "std_time": 0.0,
369
+ "min_time": 0.0,
370
+ "max_time": 0.0,
371
+ "time_span": 0.0,
372
+ "weighted_mean_time": 0.0,
373
+ "fwhm_time": 0.0,
374
+ "num_rays": 0,
375
+ }
376
+
377
+ # Basic statistics
378
+ mean_time = np.mean(times)
379
+ median_time = np.median(times)
380
+ std_time = np.std(times)
381
+ min_time = np.min(times)
382
+ max_time = np.max(times)
383
+ time_span = max_time - min_time
384
+
385
+ # Intensity-weighted mean
386
+ total_intensity = np.sum(intensities)
387
+ if total_intensity > 0:
388
+ weighted_mean_time = np.sum(times * intensities) / total_intensity
389
+ else:
390
+ weighted_mean_time = mean_time
391
+
392
+ # Estimate FWHM from histogram
393
+ hist, bin_edges = np.histogram(times, bins=100, weights=intensities)
394
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
395
+
396
+ peak_idx = np.argmax(hist)
397
+ peak_value = hist[peak_idx]
398
+ half_max = peak_value / 2
399
+
400
+ # Find indices where histogram > half max
401
+ above_half = hist >= half_max
402
+ if np.any(above_half):
403
+ indices = np.where(above_half)[0]
404
+ fwhm_time = bin_centers[indices[-1]] - bin_centers[indices[0]]
405
+ else:
406
+ fwhm_time = std_time * 2.355 # Gaussian approximation
407
+
408
+ return {
409
+ "mean_time": float(mean_time),
410
+ "median_time": float(median_time),
411
+ "std_time": float(std_time),
412
+ "min_time": float(min_time),
413
+ "max_time": float(max_time),
414
+ "time_span": float(time_span),
415
+ "weighted_mean_time": float(weighted_mean_time),
416
+ "fwhm_time": float(fwhm_time),
417
+ "num_rays": int(len(times)),
418
+ }