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.
- lsurf/__init__.py +471 -0
- lsurf/analysis/__init__.py +107 -0
- lsurf/analysis/healpix_utils.py +418 -0
- lsurf/analysis/sphere_viz.py +1280 -0
- lsurf/cli/__init__.py +48 -0
- lsurf/cli/build.py +398 -0
- lsurf/cli/config_schema.py +318 -0
- lsurf/cli/gui_cmd.py +76 -0
- lsurf/cli/interactive.py +850 -0
- lsurf/cli/main.py +81 -0
- lsurf/cli/run.py +806 -0
- lsurf/detectors/__init__.py +266 -0
- lsurf/detectors/analysis.py +289 -0
- lsurf/detectors/base.py +284 -0
- lsurf/detectors/constant_size_rings.py +485 -0
- lsurf/detectors/directional.py +45 -0
- lsurf/detectors/extended/__init__.py +73 -0
- lsurf/detectors/extended/local_sphere.py +353 -0
- lsurf/detectors/extended/recording_sphere.py +368 -0
- lsurf/detectors/planar.py +45 -0
- lsurf/detectors/protocol.py +187 -0
- lsurf/detectors/recording_spheres.py +63 -0
- lsurf/detectors/results.py +1140 -0
- lsurf/detectors/small/__init__.py +79 -0
- lsurf/detectors/small/directional.py +330 -0
- lsurf/detectors/small/planar.py +401 -0
- lsurf/detectors/small/spherical.py +450 -0
- lsurf/detectors/spherical.py +45 -0
- lsurf/geometry/__init__.py +199 -0
- lsurf/geometry/builder.py +478 -0
- lsurf/geometry/cell.py +228 -0
- lsurf/geometry/cell_geometry.py +247 -0
- lsurf/geometry/detector_arrays.py +1785 -0
- lsurf/geometry/geometry.py +222 -0
- lsurf/geometry/surface_analysis.py +375 -0
- lsurf/geometry/validation.py +91 -0
- lsurf/gui/__init__.py +51 -0
- lsurf/gui/app.py +903 -0
- lsurf/gui/core/__init__.py +39 -0
- lsurf/gui/core/scene.py +343 -0
- lsurf/gui/core/simulation.py +264 -0
- lsurf/gui/renderers/__init__.py +40 -0
- lsurf/gui/renderers/ray_renderer.py +353 -0
- lsurf/gui/renderers/source_renderer.py +505 -0
- lsurf/gui/renderers/surface_renderer.py +477 -0
- lsurf/gui/views/__init__.py +48 -0
- lsurf/gui/views/config_editor.py +3199 -0
- lsurf/gui/views/properties.py +257 -0
- lsurf/gui/views/results.py +291 -0
- lsurf/gui/views/scene_tree.py +180 -0
- lsurf/gui/views/viewport_3d.py +555 -0
- lsurf/gui/views/visualizations.py +712 -0
- lsurf/materials/__init__.py +169 -0
- lsurf/materials/base/__init__.py +64 -0
- lsurf/materials/base/full_inhomogeneous.py +208 -0
- lsurf/materials/base/grid_inhomogeneous.py +319 -0
- lsurf/materials/base/homogeneous.py +342 -0
- lsurf/materials/base/material_field.py +527 -0
- lsurf/materials/base/simple_inhomogeneous.py +418 -0
- lsurf/materials/base/spectral_inhomogeneous.py +497 -0
- lsurf/materials/implementations/__init__.py +120 -0
- lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
- lsurf/materials/implementations/duct_atmosphere.py +390 -0
- lsurf/materials/implementations/exponential_atmosphere.py +435 -0
- lsurf/materials/implementations/gaussian_lens.py +120 -0
- lsurf/materials/implementations/interpolated_data.py +123 -0
- lsurf/materials/implementations/layered_atmosphere.py +134 -0
- lsurf/materials/implementations/linear_gradient.py +109 -0
- lsurf/materials/implementations/linsley_atmosphere.py +764 -0
- lsurf/materials/implementations/standard_materials.py +126 -0
- lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
- lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
- lsurf/materials/utils/__init__.py +77 -0
- lsurf/materials/utils/constants.py +45 -0
- lsurf/materials/utils/device_functions.py +117 -0
- lsurf/materials/utils/dispersion.py +160 -0
- lsurf/materials/utils/factories.py +142 -0
- lsurf/propagation/__init__.py +91 -0
- lsurf/propagation/detector_gpu.py +67 -0
- lsurf/propagation/gpu_device_rays.py +294 -0
- lsurf/propagation/kernels/__init__.py +175 -0
- lsurf/propagation/kernels/absorption/__init__.py +61 -0
- lsurf/propagation/kernels/absorption/grid.py +240 -0
- lsurf/propagation/kernels/absorption/simple.py +232 -0
- lsurf/propagation/kernels/absorption/spectral.py +410 -0
- lsurf/propagation/kernels/detection/__init__.py +64 -0
- lsurf/propagation/kernels/detection/protocol.py +102 -0
- lsurf/propagation/kernels/detection/spherical.py +255 -0
- lsurf/propagation/kernels/device_functions.py +790 -0
- lsurf/propagation/kernels/fresnel/__init__.py +64 -0
- lsurf/propagation/kernels/fresnel/protocol.py +97 -0
- lsurf/propagation/kernels/fresnel/standard.py +258 -0
- lsurf/propagation/kernels/intersection/__init__.py +79 -0
- lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
- lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
- lsurf/propagation/kernels/intersection/plane.py +166 -0
- lsurf/propagation/kernels/intersection/protocol.py +95 -0
- lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
- lsurf/propagation/kernels/intersection/sphere.py +190 -0
- lsurf/propagation/kernels/propagation/__init__.py +85 -0
- lsurf/propagation/kernels/propagation/grid.py +527 -0
- lsurf/propagation/kernels/propagation/protocol.py +105 -0
- lsurf/propagation/kernels/propagation/simple.py +460 -0
- lsurf/propagation/kernels/propagation/spectral.py +875 -0
- lsurf/propagation/kernels/registry.py +331 -0
- lsurf/propagation/kernels/surface/__init__.py +72 -0
- lsurf/propagation/kernels/surface/bisection.py +232 -0
- lsurf/propagation/kernels/surface/detection.py +402 -0
- lsurf/propagation/kernels/surface/reduction.py +166 -0
- lsurf/propagation/propagator_protocol.py +222 -0
- lsurf/propagation/propagators/__init__.py +101 -0
- lsurf/propagation/propagators/detector_handler.py +354 -0
- lsurf/propagation/propagators/factory.py +200 -0
- lsurf/propagation/propagators/fresnel_handler.py +305 -0
- lsurf/propagation/propagators/gpu_gradient.py +566 -0
- lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
- lsurf/propagation/propagators/gradient.py +429 -0
- lsurf/propagation/propagators/intersection_handler.py +327 -0
- lsurf/propagation/propagators/material_propagator.py +398 -0
- lsurf/propagation/propagators/signed_distance_handler.py +522 -0
- lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
- lsurf/propagation/propagators/surface_interaction.py +616 -0
- lsurf/propagation/propagators/surface_propagator.py +719 -0
- lsurf/py.typed +1 -0
- lsurf/simulation/__init__.py +70 -0
- lsurf/simulation/config.py +164 -0
- lsurf/simulation/orchestrator.py +462 -0
- lsurf/simulation/result.py +299 -0
- lsurf/simulation/simulation.py +262 -0
- lsurf/sources/__init__.py +128 -0
- lsurf/sources/base.py +264 -0
- lsurf/sources/collimated.py +252 -0
- lsurf/sources/custom.py +409 -0
- lsurf/sources/diverging.py +228 -0
- lsurf/sources/gaussian.py +272 -0
- lsurf/sources/parallel_from_positions.py +197 -0
- lsurf/sources/point.py +172 -0
- lsurf/sources/uniform_diverging.py +258 -0
- lsurf/surfaces/__init__.py +184 -0
- lsurf/surfaces/cpu/__init__.py +50 -0
- lsurf/surfaces/cpu/curved_wave.py +463 -0
- lsurf/surfaces/cpu/gerstner_wave.py +381 -0
- lsurf/surfaces/cpu/wave_params.py +118 -0
- lsurf/surfaces/gpu/__init__.py +72 -0
- lsurf/surfaces/gpu/annular_plane.py +453 -0
- lsurf/surfaces/gpu/bounded_plane.py +390 -0
- lsurf/surfaces/gpu/curved_wave.py +483 -0
- lsurf/surfaces/gpu/gerstner_wave.py +377 -0
- lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
- lsurf/surfaces/gpu/plane.py +299 -0
- lsurf/surfaces/gpu/recording_sphere.py +587 -0
- lsurf/surfaces/gpu/sphere.py +311 -0
- lsurf/surfaces/protocol.py +336 -0
- lsurf/surfaces/registry.py +373 -0
- lsurf/utilities/__init__.py +175 -0
- lsurf/utilities/detector_analysis.py +814 -0
- lsurf/utilities/fresnel.py +628 -0
- lsurf/utilities/interactions.py +1215 -0
- lsurf/utilities/propagation.py +602 -0
- lsurf/utilities/ray_data.py +532 -0
- lsurf/utilities/recording_sphere.py +745 -0
- lsurf/utilities/time_spread.py +463 -0
- lsurf/visualization/__init__.py +329 -0
- lsurf/visualization/absorption_plots.py +334 -0
- lsurf/visualization/atmospheric_plots.py +754 -0
- lsurf/visualization/common.py +348 -0
- lsurf/visualization/detector_plots.py +1350 -0
- lsurf/visualization/detector_sphere_plots.py +1173 -0
- lsurf/visualization/fresnel_plots.py +1061 -0
- lsurf/visualization/ocean_simulation_plots.py +999 -0
- lsurf/visualization/polarization_plots.py +916 -0
- lsurf/visualization/raytracing_plots.py +1521 -0
- lsurf/visualization/ring_detector_plots.py +1867 -0
- lsurf/visualization/time_spread_plots.py +531 -0
- lsurf-1.0.0.dist-info/METADATA +381 -0
- lsurf-1.0.0.dist-info/RECORD +180 -0
- lsurf-1.0.0.dist-info/WHEEL +5 -0
- lsurf-1.0.0.dist-info/entry_points.txt +2 -0
- lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
- 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
|
+
}
|