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,814 @@
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
+ Detector Analysis Utilities
36
+
37
+ Functions for analyzing detector data including peak irradiance (W/m²)
38
+ and Pareto front computation for irradiance vs time spread trade-offs.
39
+ """
40
+
41
+ from typing import Any
42
+
43
+ import numpy as np
44
+ from numpy.typing import NDArray
45
+
46
+ from ..analysis.healpix_utils import (
47
+ HAS_HEALPIX,
48
+ rays_to_healpix,
49
+ )
50
+
51
+
52
+ def weighted_percentile(
53
+ values: NDArray,
54
+ weights: NDArray,
55
+ percentile: float,
56
+ ) -> float:
57
+ """
58
+ Compute weighted percentile.
59
+
60
+ Parameters
61
+ ----------
62
+ values : ndarray
63
+ Data values
64
+ weights : ndarray
65
+ Weights for each value (e.g., ray intensities)
66
+ percentile : float
67
+ Percentile to compute (0-100)
68
+
69
+ Returns
70
+ -------
71
+ float
72
+ The weighted percentile value
73
+ """
74
+ if len(values) == 0:
75
+ return 0.0
76
+
77
+ # Sort by values
78
+ sort_idx = np.argsort(values)
79
+ sorted_values = values[sort_idx]
80
+ sorted_weights = weights[sort_idx]
81
+
82
+ # Compute cumulative weights (normalized to 0-1)
83
+ cumsum = np.cumsum(sorted_weights)
84
+ cumsum_normalized = cumsum / cumsum[-1]
85
+
86
+ # Find where cumulative weight crosses the percentile threshold
87
+ threshold = percentile / 100.0
88
+
89
+ # Use linear interpolation
90
+ idx = np.searchsorted(cumsum_normalized, threshold)
91
+
92
+ if idx == 0:
93
+ return float(sorted_values[0])
94
+ if idx >= len(sorted_values):
95
+ return float(sorted_values[-1])
96
+
97
+ # Linear interpolation between adjacent points
98
+ w_low = cumsum_normalized[idx - 1]
99
+ w_high = cumsum_normalized[idx]
100
+ v_low = sorted_values[idx - 1]
101
+ v_high = sorted_values[idx]
102
+
103
+ if w_high == w_low:
104
+ return float(v_low)
105
+
106
+ # Interpolate
107
+ frac = (threshold - w_low) / (w_high - w_low)
108
+ return float(v_low + frac * (v_high - v_low))
109
+
110
+
111
+ def find_peak_irradiance_local(
112
+ recorded_rays,
113
+ detector_radius: float,
114
+ detector_center: NDArray | None = None,
115
+ n_bins: int = 50,
116
+ bin_size_meters: float | None = None,
117
+ ) -> dict[str, Any]:
118
+ """
119
+ Find peak irradiance using local tangent plane coordinates.
120
+
121
+ Uses a local East-North coordinate system centered on the peak region,
122
+ providing constant resolution in physical units (meters). This avoids
123
+ the polar singularities and anisotropic bin sizes of lat/lon binning.
124
+
125
+ Parameters
126
+ ----------
127
+ recorded_rays : RecordedRays
128
+ Recorded rays on detection sphere
129
+ detector_radius : float
130
+ Radius of the detector sphere (m)
131
+ detector_center : array-like, optional
132
+ Center of the detector sphere (default origin)
133
+ n_bins : int
134
+ Number of bins in each dimension (default 50)
135
+ bin_size_meters : float, optional
136
+ Physical size of each bin in meters. If None, auto-computed
137
+ to cover the data extent with n_bins.
138
+
139
+ Returns
140
+ -------
141
+ dict
142
+ Dictionary containing:
143
+ - peak_east, peak_north: Peak location in local coordinates (m)
144
+ - peak_lon, peak_lat: Peak location in radians
145
+ - peak_lon_deg, peak_lat_deg: Peak location in degrees
146
+ - peak_position: Peak location in Cartesian (x, y, z)
147
+ - peak_irradiance: Irradiance at peak (W/m²)
148
+ - total_power: Total detected power (W)
149
+ - histogram: 2D irradiance histogram
150
+ - east_edges, north_edges: Bin edges in local coords (m)
151
+ - bin_size: Physical bin size (m)
152
+ """
153
+ positions = recorded_rays.positions
154
+ intensities = recorded_rays.intensities
155
+
156
+ # Convert to spherical coordinates relative to detector center
157
+ if detector_center is not None:
158
+ positions = positions - np.array(detector_center)
159
+
160
+ x, y, z = positions[:, 0], positions[:, 1], positions[:, 2]
161
+ r = np.sqrt(x**2 + y**2 + z**2)
162
+
163
+ lat = np.arcsin(z / r)
164
+ lon = np.arctan2(y, x)
165
+
166
+ # Step 1: Find approximate peak using intensity-weighted centroid
167
+ total_intensity = np.sum(intensities)
168
+ if total_intensity > 0:
169
+ peak_lat_approx = np.sum(lat * intensities) / total_intensity
170
+ peak_lon_approx = np.sum(lon * intensities) / total_intensity
171
+ else:
172
+ peak_lat_approx = np.mean(lat)
173
+ peak_lon_approx = np.mean(lon)
174
+
175
+ # Step 2: Compute local tangent plane basis vectors at approximate peak
176
+ # Radial direction (outward from sphere center)
177
+ radial = np.array(
178
+ [
179
+ np.cos(peak_lat_approx) * np.cos(peak_lon_approx),
180
+ np.cos(peak_lat_approx) * np.sin(peak_lon_approx),
181
+ np.sin(peak_lat_approx),
182
+ ]
183
+ )
184
+
185
+ # East direction (tangent to latitude circles, pointing east)
186
+ east = np.array([-np.sin(peak_lon_approx), np.cos(peak_lon_approx), 0.0])
187
+ east = east / np.linalg.norm(east)
188
+
189
+ # North direction (tangent to longitude circles, pointing north)
190
+ north = np.cross(radial, east)
191
+ north = north / np.linalg.norm(north)
192
+
193
+ # Step 3: Project all ray positions onto local tangent plane
194
+ # Position on sphere relative to peak point
195
+ peak_point = detector_radius * radial
196
+ rel_positions = positions - peak_point
197
+
198
+ # Project onto East-North plane (in meters)
199
+ local_east = np.dot(rel_positions, east)
200
+ local_north = np.dot(rel_positions, north)
201
+
202
+ # Step 4: Determine bin size
203
+ east_extent = local_east.max() - local_east.min()
204
+ north_extent = local_north.max() - local_north.min()
205
+
206
+ if bin_size_meters is None:
207
+ # Auto-compute to cover data with n_bins
208
+ bin_size = max(east_extent, north_extent) / n_bins * 1.1 # 10% padding
209
+ bin_size = max(bin_size, 1.0) # Minimum 1 meter bins
210
+ else:
211
+ bin_size = bin_size_meters
212
+
213
+ # Step 5: Create uniform grid in local coordinates
214
+ east_min = local_east.min() - bin_size
215
+ east_max = local_east.max() + bin_size
216
+ north_min = local_north.min() - bin_size
217
+ north_max = local_north.max() + bin_size
218
+
219
+ n_east_bins = max(1, int(np.ceil((east_max - east_min) / bin_size)))
220
+ n_north_bins = max(1, int(np.ceil((north_max - north_min) / bin_size)))
221
+
222
+ east_edges = np.linspace(
223
+ east_min, east_min + n_east_bins * bin_size, n_east_bins + 1
224
+ )
225
+ north_edges = np.linspace(
226
+ north_min, north_min + n_north_bins * bin_size, n_north_bins + 1
227
+ )
228
+
229
+ # Step 6: Bin rays and compute irradiance
230
+ hist, _, _ = np.histogram2d(
231
+ local_east, local_north, bins=[east_edges, north_edges], weights=intensities
232
+ )
233
+
234
+ # Physical area per bin (constant for all bins!)
235
+ bin_area = bin_size**2 # m²
236
+
237
+ # Irradiance = power / area (W/m²)
238
+ irradiance = hist / bin_area
239
+
240
+ # Step 7: Find peak
241
+ peak_idx = np.unravel_index(np.argmax(irradiance), irradiance.shape)
242
+ peak_east_bin, peak_north_bin = peak_idx
243
+
244
+ east_centers = (east_edges[:-1] + east_edges[1:]) / 2
245
+ north_centers = (north_edges[:-1] + north_edges[1:]) / 2
246
+
247
+ peak_east = east_centers[peak_east_bin]
248
+ peak_north = north_centers[peak_north_bin]
249
+
250
+ # Refine peak using intensity-weighted centroid in neighborhood
251
+ east_min_idx = max(0, peak_east_bin - 1)
252
+ east_max_idx = min(n_east_bins, peak_east_bin + 2)
253
+ north_min_idx = max(0, peak_north_bin - 1)
254
+ north_max_idx = min(n_north_bins, peak_north_bin + 2)
255
+
256
+ neighborhood_mask = (
257
+ (local_east >= east_edges[east_min_idx])
258
+ & (local_east < east_edges[east_max_idx])
259
+ & (local_north >= north_edges[north_min_idx])
260
+ & (local_north < north_edges[north_max_idx])
261
+ )
262
+
263
+ if np.sum(neighborhood_mask) > 0:
264
+ weights = intensities[neighborhood_mask]
265
+ total_weight = np.sum(weights)
266
+ if total_weight > 0:
267
+ peak_east = np.sum(local_east[neighborhood_mask] * weights) / total_weight
268
+ peak_north = np.sum(local_north[neighborhood_mask] * weights) / total_weight
269
+
270
+ # Step 8: Convert peak back to global coordinates
271
+ peak_local_3d = peak_point + peak_east * east + peak_north * north
272
+ peak_r = np.linalg.norm(peak_local_3d)
273
+
274
+ # Normalize to sphere surface
275
+ peak_position = peak_local_3d * (detector_radius / peak_r)
276
+
277
+ # Get lat/lon of peak
278
+ peak_lat = np.arcsin(peak_position[2] / detector_radius)
279
+ peak_lon = np.arctan2(peak_position[1], peak_position[0])
280
+
281
+ return {
282
+ "peak_east": peak_east,
283
+ "peak_north": peak_north,
284
+ "peak_lon": peak_lon,
285
+ "peak_lat": peak_lat,
286
+ "peak_lon_deg": np.degrees(peak_lon),
287
+ "peak_lat_deg": np.degrees(peak_lat),
288
+ "peak_position": peak_position,
289
+ "peak_irradiance": irradiance[peak_idx],
290
+ "total_power": np.sum(intensities),
291
+ "histogram": irradiance,
292
+ "east_edges": east_edges,
293
+ "north_edges": north_edges,
294
+ "east_centers": east_centers,
295
+ "north_centers": north_centers,
296
+ "bin_size": bin_size,
297
+ "local_basis": {"east": east, "north": north, "radial": radial},
298
+ "tangent_point": peak_point,
299
+ }
300
+
301
+
302
+ def find_peak_energy_density(
303
+ recorded_rays,
304
+ detector_radius: float,
305
+ detector_center: NDArray | None = None,
306
+ n_bins: int = 50,
307
+ ) -> dict[str, Any]:
308
+ """
309
+ Scan the detector sphere to find the location with highest energy density.
310
+
311
+ Parameters
312
+ ----------
313
+ recorded_rays : RecordedRays
314
+ Recorded rays on detection sphere
315
+ detector_radius : float
316
+ Radius of the detector sphere (m)
317
+ detector_center : array-like, optional
318
+ Center of the detector sphere (default origin)
319
+ n_bins : int
320
+ Number of bins in each angular dimension
321
+
322
+ Returns
323
+ -------
324
+ dict
325
+ Dictionary containing:
326
+ - peak_lon, peak_lat: Peak location in radians
327
+ - peak_lon_deg, peak_lat_deg: Peak location in degrees
328
+ - peak_position: Peak location in Cartesian (x, y, z)
329
+ - peak_irradiance: Irradiance at peak (W/m²)
330
+ - total_power: Total detected power
331
+ - histogram: 2D irradiance histogram
332
+ - lon_edges, lat_edges: Bin edges
333
+ - lon_centers, lat_centers: Bin centers
334
+
335
+ Notes
336
+ -----
337
+ This function uses lat/lon binning which has anisotropic resolution
338
+ (bins are smaller near poles). For more uniform resolution, use
339
+ find_peak_irradiance_local() which uses local tangent plane coordinates.
340
+ """
341
+ positions = recorded_rays.positions
342
+ intensities = recorded_rays.intensities
343
+
344
+ # Convert to spherical coordinates relative to detector center
345
+ if detector_center is not None:
346
+ positions = positions - np.array(detector_center)
347
+
348
+ x, y, z = positions[:, 0], positions[:, 1], positions[:, 2]
349
+ r = np.sqrt(x**2 + y**2 + z**2)
350
+
351
+ lat = np.arcsin(z / r) # -pi/2 to pi/2
352
+ lon = np.arctan2(y, x) # -pi to pi
353
+
354
+ # Determine bin ranges based on data extent (with small padding)
355
+ lat_min, lat_max = lat.min(), lat.max()
356
+ lon_min, lon_max = lon.min(), lon.max()
357
+
358
+ lat_padding = (lat_max - lat_min) * 0.1 + 0.01
359
+ lon_padding = (lon_max - lon_min) * 0.1 + 0.01
360
+
361
+ lat_range = (lat_min - lat_padding, lat_max + lat_padding)
362
+ lon_range = (lon_min - lon_padding, lon_max + lon_padding)
363
+
364
+ # Create 2D histogram weighted by intensity
365
+ hist, lon_edges, lat_edges = np.histogram2d(
366
+ lon, lat, bins=n_bins, range=[lon_range, lat_range], weights=intensities
367
+ )
368
+
369
+ # Compute solid angle per bin for energy density (W/sr)
370
+ # Solid angle element: dΩ = cos(lat) * dlat * dlon
371
+ dlat = lat_edges[1] - lat_edges[0]
372
+ dlon = lon_edges[1] - lon_edges[0]
373
+
374
+ lat_centers = (lat_edges[:-1] + lat_edges[1:]) / 2
375
+ solid_angles = np.abs(np.cos(lat_centers)) * dlat * dlon
376
+
377
+ # Energy density = power / solid_angle (W/sr)
378
+ energy_density = hist / solid_angles[np.newaxis, :]
379
+
380
+ # Also compute physical area per bin for irradiance (W/m²)
381
+ # Area element on sphere: dA = R² * cos(lat) * dlat * dlon = R² * dΩ
382
+ physical_areas = (detector_radius**2) * solid_angles
383
+
384
+ # Irradiance = power / area (W/m²)
385
+ irradiance = hist / physical_areas[np.newaxis, :]
386
+
387
+ # Find peak bin (use energy density for consistency with original behavior)
388
+ peak_idx = np.unravel_index(np.argmax(energy_density), energy_density.shape)
389
+ peak_lon_bin, peak_lat_bin = peak_idx
390
+
391
+ # Get bin center coordinates
392
+ lon_centers = (lon_edges[:-1] + lon_edges[1:]) / 2
393
+ peak_lon = lon_centers[peak_lon_bin]
394
+ peak_lat = lat_centers[peak_lat_bin]
395
+
396
+ # Refine peak location using intensity-weighted centroid in neighborhood
397
+ # Use 3x3 neighborhood around peak
398
+ lon_min_idx = max(0, peak_lon_bin - 1)
399
+ lon_max_idx = min(n_bins, peak_lon_bin + 2)
400
+ lat_min_idx = max(0, peak_lat_bin - 1)
401
+ lat_max_idx = min(n_bins, peak_lat_bin + 2)
402
+
403
+ # Find rays within the neighborhood bins
404
+ neighborhood_mask = (
405
+ (lon >= lon_edges[lon_min_idx])
406
+ & (lon < lon_edges[lon_max_idx])
407
+ & (lat >= lat_edges[lat_min_idx])
408
+ & (lat < lat_edges[lat_max_idx])
409
+ )
410
+
411
+ if np.sum(neighborhood_mask) > 0:
412
+ weights = intensities[neighborhood_mask]
413
+ total_weight = np.sum(weights)
414
+ if total_weight > 0:
415
+ peak_lon = np.sum(lon[neighborhood_mask] * weights) / total_weight
416
+ peak_lat = np.sum(lat[neighborhood_mask] * weights) / total_weight
417
+
418
+ # Convert peak to Cartesian coordinates on sphere
419
+ peak_x = detector_radius * np.cos(peak_lat) * np.cos(peak_lon)
420
+ peak_y = detector_radius * np.cos(peak_lat) * np.sin(peak_lon)
421
+ peak_z = detector_radius * np.sin(peak_lat)
422
+
423
+ return {
424
+ "peak_lon": peak_lon,
425
+ "peak_lat": peak_lat,
426
+ "peak_lon_deg": np.degrees(peak_lon),
427
+ "peak_lat_deg": np.degrees(peak_lat),
428
+ "peak_position": np.array([peak_x, peak_y, peak_z]),
429
+ "peak_energy_density": energy_density[peak_idx], # W/sr
430
+ "peak_irradiance": irradiance[peak_idx], # W/m²
431
+ "total_power": np.sum(intensities),
432
+ "histogram": energy_density, # W/sr (original behavior)
433
+ "histogram_irradiance": irradiance, # W/m²
434
+ "lon_edges": lon_edges,
435
+ "lat_edges": lat_edges,
436
+ "lon_centers": lon_centers,
437
+ "lat_centers": lat_centers,
438
+ }
439
+
440
+
441
+ def compute_pareto_front(
442
+ recorded_rays,
443
+ detector_radius: float,
444
+ detector_center: NDArray | None = None,
445
+ n_bins: int = 30,
446
+ min_rays_per_bin: int = 5,
447
+ ) -> dict[str, Any]:
448
+ """
449
+ Compute Pareto front for irradiance vs time spread trade-off.
450
+
451
+ Parameters
452
+ ----------
453
+ recorded_rays : RecordedRays
454
+ Recorded rays on detection sphere
455
+ detector_radius : float
456
+ Radius of the detector sphere (m)
457
+ detector_center : array-like, optional
458
+ Center of the detector sphere (default origin)
459
+ n_bins : int
460
+ Number of bins in each angular dimension
461
+ min_rays_per_bin : int
462
+ Minimum rays required per bin for reliable statistics
463
+
464
+ Returns
465
+ -------
466
+ dict
467
+ Dictionary containing:
468
+ - bin_data: List of dicts with (lon, lat, irradiance, time_spread, n_rays)
469
+ - pareto_indices: Indices of Pareto-optimal bins
470
+ - pareto_front: List of Pareto-optimal bin data
471
+ - all_irradiances: Array of all bin irradiances (W/m²)
472
+ - all_time_spreads: Array of all bin time spreads
473
+ """
474
+ positions = recorded_rays.positions
475
+ intensities = recorded_rays.intensities
476
+ times = recorded_rays.times
477
+
478
+ # Convert to spherical coordinates relative to detector center
479
+ if detector_center is not None:
480
+ positions = positions - np.array(detector_center)
481
+
482
+ x, y, z = positions[:, 0], positions[:, 1], positions[:, 2]
483
+ r = np.sqrt(x**2 + y**2 + z**2)
484
+ lat = np.arcsin(z / r)
485
+ lon = np.arctan2(y, x)
486
+
487
+ # Determine bin ranges based on data extent
488
+ lat_min, lat_max = lat.min(), lat.max()
489
+ lon_min, lon_max = lon.min(), lon.max()
490
+
491
+ lat_padding = (lat_max - lat_min) * 0.05 + 0.001
492
+ lon_padding = (lon_max - lon_min) * 0.05 + 0.001
493
+
494
+ lat_edges = np.linspace(lat_min - lat_padding, lat_max + lat_padding, n_bins + 1)
495
+ lon_edges = np.linspace(lon_min - lon_padding, lon_max + lon_padding, n_bins + 1)
496
+
497
+ dlat = lat_edges[1] - lat_edges[0]
498
+ dlon = lon_edges[1] - lon_edges[0]
499
+
500
+ # Digitize rays into bins
501
+ lon_bin_idx = np.digitize(lon, lon_edges) - 1
502
+ lat_bin_idx = np.digitize(lat, lat_edges) - 1
503
+
504
+ # Clamp to valid range
505
+ lon_bin_idx = np.clip(lon_bin_idx, 0, n_bins - 1)
506
+ lat_bin_idx = np.clip(lat_bin_idx, 0, n_bins - 1)
507
+
508
+ # Compute metrics for each bin
509
+ bin_data: list[dict[str, Any]] = []
510
+
511
+ for i in range(n_bins):
512
+ for j in range(n_bins):
513
+ mask = (lon_bin_idx == i) & (lat_bin_idx == j)
514
+ n_rays = np.sum(mask)
515
+
516
+ if n_rays >= min_rays_per_bin:
517
+ bin_intensities = intensities[mask]
518
+ bin_times = times[mask]
519
+
520
+ # Energy density (W/sr)
521
+ lat_center = (lat_edges[j] + lat_edges[j + 1]) / 2
522
+ solid_angle = np.abs(np.cos(lat_center)) * dlat * dlon
523
+ energy_density = np.sum(bin_intensities) / solid_angle
524
+
525
+ # Time spread: intensity-weighted 90th - 10th percentile
526
+ # This ensures low-intensity multi-bounce rays contribute
527
+ # proportionally less to the time spread metric
528
+ time_90 = weighted_percentile(bin_times, bin_intensities, 90)
529
+ time_10 = weighted_percentile(bin_times, bin_intensities, 10)
530
+ time_spread_ns = (time_90 - time_10) * 1e9
531
+
532
+ lon_center = (lon_edges[i] + lon_edges[i + 1]) / 2
533
+
534
+ bin_data.append(
535
+ {
536
+ "lon": lon_center,
537
+ "lat": lat_center,
538
+ "lon_deg": np.degrees(lon_center),
539
+ "lat_deg": np.degrees(lat_center),
540
+ "energy_density": energy_density,
541
+ "time_spread_ns": time_spread_ns,
542
+ "n_rays": int(n_rays),
543
+ "total_intensity": np.sum(bin_intensities),
544
+ }
545
+ )
546
+
547
+ if len(bin_data) == 0:
548
+ return {
549
+ "bin_data": [],
550
+ "pareto_indices": np.array([], dtype=int),
551
+ "pareto_front": [],
552
+ "all_energy_densities": np.array([]),
553
+ "all_time_spreads": np.array([]),
554
+ }
555
+
556
+ # Find Pareto front
557
+ # A point is Pareto-optimal if no other point has BOTH:
558
+ # - higher energy density AND lower time spread
559
+ energy_densities = np.array([b["energy_density"] for b in bin_data])
560
+ time_spreads = np.array([b["time_spread_ns"] for b in bin_data])
561
+
562
+ n_points = len(bin_data)
563
+ is_pareto = np.ones(n_points, dtype=bool)
564
+
565
+ for i in range(n_points):
566
+ for j in range(n_points):
567
+ if i != j:
568
+ # Check if j dominates i
569
+ # j dominates i if j has higher energy AND lower time spread
570
+ if (
571
+ energy_densities[j] > energy_densities[i]
572
+ and time_spreads[j] < time_spreads[i]
573
+ ):
574
+ is_pareto[i] = False
575
+ break
576
+
577
+ pareto_indices = np.where(is_pareto)[0]
578
+ pareto_front = [bin_data[i] for i in pareto_indices]
579
+
580
+ # Sort Pareto front by time spread (ascending)
581
+ pareto_front = sorted(pareto_front, key=lambda x: x["time_spread_ns"])
582
+
583
+ return {
584
+ "bin_data": bin_data,
585
+ "pareto_indices": pareto_indices,
586
+ "pareto_front": pareto_front,
587
+ "all_energy_densities": energy_densities,
588
+ "all_time_spreads": time_spreads,
589
+ }
590
+
591
+
592
+ def analyze_healpix_detector(
593
+ recorded_rays,
594
+ detector_radius: float,
595
+ nside: int = 128,
596
+ min_rays_per_pixel: int = 5,
597
+ ) -> dict[str, Any]:
598
+ """
599
+ Analyze detected rays using HEALPix equal-area pixelization.
600
+
601
+ HEALPix provides equal-area pixels on the sphere, avoiding the
602
+ polar singularities and anisotropic bin sizes of lat/lon binning.
603
+
604
+ Parameters
605
+ ----------
606
+ recorded_rays : RecordedRays
607
+ Recorded ray data from detection sphere
608
+ detector_radius : float
609
+ Radius of detection sphere in meters
610
+ nside : int
611
+ HEALPix resolution parameter (default 128 → ~13,000 pixels)
612
+ nside=64 → ~3,400 pixels
613
+ nside=128 → ~13,000 pixels
614
+ nside=256 → ~50,000 pixels
615
+ min_rays_per_pixel : int
616
+ Minimum rays required per pixel for valid statistics
617
+
618
+ Returns
619
+ -------
620
+ dict
621
+ Dictionary containing:
622
+ - peak_pixel_id: HEALPix pixel with highest irradiance
623
+ - peak_lon_deg, peak_lat_deg: Peak location in degrees
624
+ - peak_irradiance: Irradiance at peak (W/m²)
625
+ - total_power: Total detected power (W)
626
+ - pixel_area: Area of each HEALPix pixel (m²)
627
+ - pixel_data: List of dicts with per-pixel data:
628
+ - pixel_id, lon_deg, lat_deg
629
+ - irradiance (W/m²)
630
+ - time_spread_ns (90th-10th percentile)
631
+ - ray_count
632
+ - healpix_data: Full HEALPixData object
633
+
634
+ Raises
635
+ ------
636
+ ImportError
637
+ If astropy-healpix is not installed
638
+ """
639
+ if not HAS_HEALPIX:
640
+ raise ImportError(
641
+ "astropy-healpix is required. Install with: pip install astropy-healpix"
642
+ )
643
+
644
+ # Convert rays to HEALPix representation
645
+ healpix_data = rays_to_healpix(recorded_rays, nside=nside)
646
+
647
+ # Compute equal pixel area (constant for all HEALPix pixels)
648
+ npix = 12 * nside**2
649
+ solid_angle_per_pixel = 4 * np.pi / npix # steradians
650
+ pixel_area = detector_radius**2 * solid_angle_per_pixel # m²
651
+
652
+ # Get unique pixel indices
653
+ unique_pixels = np.unique(healpix_data.pixel_indices)
654
+
655
+ # Compute per-pixel statistics
656
+ pixel_data: list[dict[str, Any]] = []
657
+ peak_irradiance = 0.0
658
+ peak_pixel_id = -1
659
+ peak_lon_deg = 0.0
660
+ peak_lat_deg = 0.0
661
+
662
+ for pixel_id in unique_pixels:
663
+ mask = healpix_data.pixel_indices == pixel_id
664
+ n_rays = np.sum(mask)
665
+
666
+ if n_rays < min_rays_per_pixel:
667
+ continue
668
+
669
+ # Get rays in this pixel
670
+ pixel_intensities = healpix_data.intensities[mask]
671
+ pixel_times = healpix_data.times[mask]
672
+ pixel_lons = healpix_data.lon[mask]
673
+ pixel_lats = healpix_data.lat[mask]
674
+
675
+ # Sum intensity and compute irradiance
676
+ intensity_sum = np.sum(pixel_intensities)
677
+ irradiance = intensity_sum / pixel_area # W/m²
678
+
679
+ # Time spread: intensity-weighted 90th - 10th percentile
680
+ if len(pixel_times) >= 2:
681
+ time_90 = weighted_percentile(pixel_times, pixel_intensities, 90)
682
+ time_10 = weighted_percentile(pixel_times, pixel_intensities, 10)
683
+ time_spread_ns = (time_90 - time_10) * 1e9
684
+ else:
685
+ time_spread_ns = 0.0
686
+
687
+ # Mean position (intensity-weighted)
688
+ total_intensity = intensity_sum if intensity_sum > 0 else 1.0
689
+ mean_lon = np.sum(pixel_lons * pixel_intensities) / total_intensity
690
+ mean_lat = np.sum(pixel_lats * pixel_intensities) / total_intensity
691
+
692
+ pixel_data.append(
693
+ {
694
+ "pixel_id": int(pixel_id),
695
+ "lon_deg": np.degrees(mean_lon),
696
+ "lat_deg": np.degrees(mean_lat),
697
+ "irradiance": irradiance,
698
+ "intensity_sum": intensity_sum,
699
+ "time_spread_ns": time_spread_ns,
700
+ "ray_count": int(n_rays),
701
+ }
702
+ )
703
+
704
+ # Track peak
705
+ if irradiance > peak_irradiance:
706
+ peak_irradiance = irradiance
707
+ peak_pixel_id = int(pixel_id)
708
+ peak_lon_deg = np.degrees(mean_lon)
709
+ peak_lat_deg = np.degrees(mean_lat)
710
+
711
+ return {
712
+ "peak_pixel_id": peak_pixel_id,
713
+ "peak_lon_deg": peak_lon_deg,
714
+ "peak_lat_deg": peak_lat_deg,
715
+ "peak_irradiance": peak_irradiance,
716
+ "total_power": np.sum(healpix_data.intensities),
717
+ "pixel_area": pixel_area,
718
+ "nside": nside,
719
+ "npix": npix,
720
+ "pixel_data": pixel_data,
721
+ "healpix_data": healpix_data,
722
+ }
723
+
724
+
725
+ def compute_healpix_pareto_front(
726
+ recorded_rays,
727
+ detector_radius: float,
728
+ nside: int = 64,
729
+ min_rays_per_pixel: int = 10,
730
+ ) -> dict[str, Any]:
731
+ """
732
+ Compute Pareto front for irradiance vs time spread using HEALPix.
733
+
734
+ A pixel is Pareto-optimal if no other pixel has BOTH higher irradiance
735
+ AND lower time spread.
736
+
737
+ Parameters
738
+ ----------
739
+ recorded_rays : RecordedRays
740
+ Recorded ray data from detection sphere
741
+ detector_radius : float
742
+ Radius of detection sphere in meters
743
+ nside : int
744
+ HEALPix resolution parameter (default 64 → ~3,400 pixels)
745
+ min_rays_per_pixel : int
746
+ Minimum rays required per pixel for valid statistics
747
+
748
+ Returns
749
+ -------
750
+ dict
751
+ Dictionary containing:
752
+ - pixel_data: List of all valid pixel dicts
753
+ - pareto_indices: Indices of Pareto-optimal pixels
754
+ - pareto_front: List of Pareto-optimal pixel data
755
+ - all_irradiances: Array of all pixel irradiances
756
+ - all_time_spreads: Array of all pixel time spreads
757
+
758
+ Raises
759
+ ------
760
+ ImportError
761
+ If astropy-healpix is not installed
762
+ """
763
+ # Use analyze_healpix_detector to get pixel data
764
+ result = analyze_healpix_detector(
765
+ recorded_rays,
766
+ detector_radius,
767
+ nside=nside,
768
+ min_rays_per_pixel=min_rays_per_pixel,
769
+ )
770
+
771
+ pixel_data = result["pixel_data"]
772
+
773
+ if len(pixel_data) == 0:
774
+ return {
775
+ "pixel_data": [],
776
+ "pareto_indices": np.array([], dtype=int),
777
+ "pareto_front": [],
778
+ "all_irradiances": np.array([]),
779
+ "all_time_spreads": np.array([]),
780
+ }
781
+
782
+ # Extract arrays for Pareto computation
783
+ irradiances = np.array([p["irradiance"] for p in pixel_data])
784
+ time_spreads = np.array([p["time_spread_ns"] for p in pixel_data])
785
+
786
+ # Find Pareto front
787
+ n_points = len(pixel_data)
788
+ is_pareto = np.ones(n_points, dtype=bool)
789
+
790
+ for i in range(n_points):
791
+ for j in range(n_points):
792
+ if i != j:
793
+ # Check if j dominates i
794
+ # j dominates i if j has higher irradiance AND lower time spread
795
+ if (
796
+ irradiances[j] > irradiances[i]
797
+ and time_spreads[j] < time_spreads[i]
798
+ ):
799
+ is_pareto[i] = False
800
+ break
801
+
802
+ pareto_indices = np.where(is_pareto)[0]
803
+ pareto_front = [pixel_data[i] for i in pareto_indices]
804
+
805
+ # Sort Pareto front by time spread (ascending)
806
+ pareto_front = sorted(pareto_front, key=lambda x: x["time_spread_ns"])
807
+
808
+ return {
809
+ "pixel_data": pixel_data,
810
+ "pareto_indices": pareto_indices,
811
+ "pareto_front": pareto_front,
812
+ "all_irradiances": irradiances,
813
+ "all_time_spreads": time_spreads,
814
+ }