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