lsurf 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. lsurf/__init__.py +471 -0
  2. lsurf/analysis/__init__.py +107 -0
  3. lsurf/analysis/healpix_utils.py +418 -0
  4. lsurf/analysis/sphere_viz.py +1280 -0
  5. lsurf/cli/__init__.py +48 -0
  6. lsurf/cli/build.py +398 -0
  7. lsurf/cli/config_schema.py +318 -0
  8. lsurf/cli/gui_cmd.py +76 -0
  9. lsurf/cli/interactive.py +850 -0
  10. lsurf/cli/main.py +81 -0
  11. lsurf/cli/run.py +806 -0
  12. lsurf/detectors/__init__.py +266 -0
  13. lsurf/detectors/analysis.py +289 -0
  14. lsurf/detectors/base.py +284 -0
  15. lsurf/detectors/constant_size_rings.py +485 -0
  16. lsurf/detectors/directional.py +45 -0
  17. lsurf/detectors/extended/__init__.py +73 -0
  18. lsurf/detectors/extended/local_sphere.py +353 -0
  19. lsurf/detectors/extended/recording_sphere.py +368 -0
  20. lsurf/detectors/planar.py +45 -0
  21. lsurf/detectors/protocol.py +187 -0
  22. lsurf/detectors/recording_spheres.py +63 -0
  23. lsurf/detectors/results.py +1140 -0
  24. lsurf/detectors/small/__init__.py +79 -0
  25. lsurf/detectors/small/directional.py +330 -0
  26. lsurf/detectors/small/planar.py +401 -0
  27. lsurf/detectors/small/spherical.py +450 -0
  28. lsurf/detectors/spherical.py +45 -0
  29. lsurf/geometry/__init__.py +199 -0
  30. lsurf/geometry/builder.py +478 -0
  31. lsurf/geometry/cell.py +228 -0
  32. lsurf/geometry/cell_geometry.py +247 -0
  33. lsurf/geometry/detector_arrays.py +1785 -0
  34. lsurf/geometry/geometry.py +222 -0
  35. lsurf/geometry/surface_analysis.py +375 -0
  36. lsurf/geometry/validation.py +91 -0
  37. lsurf/gui/__init__.py +51 -0
  38. lsurf/gui/app.py +903 -0
  39. lsurf/gui/core/__init__.py +39 -0
  40. lsurf/gui/core/scene.py +343 -0
  41. lsurf/gui/core/simulation.py +264 -0
  42. lsurf/gui/renderers/__init__.py +40 -0
  43. lsurf/gui/renderers/ray_renderer.py +353 -0
  44. lsurf/gui/renderers/source_renderer.py +505 -0
  45. lsurf/gui/renderers/surface_renderer.py +477 -0
  46. lsurf/gui/views/__init__.py +48 -0
  47. lsurf/gui/views/config_editor.py +3199 -0
  48. lsurf/gui/views/properties.py +257 -0
  49. lsurf/gui/views/results.py +291 -0
  50. lsurf/gui/views/scene_tree.py +180 -0
  51. lsurf/gui/views/viewport_3d.py +555 -0
  52. lsurf/gui/views/visualizations.py +712 -0
  53. lsurf/materials/__init__.py +169 -0
  54. lsurf/materials/base/__init__.py +64 -0
  55. lsurf/materials/base/full_inhomogeneous.py +208 -0
  56. lsurf/materials/base/grid_inhomogeneous.py +319 -0
  57. lsurf/materials/base/homogeneous.py +342 -0
  58. lsurf/materials/base/material_field.py +527 -0
  59. lsurf/materials/base/simple_inhomogeneous.py +418 -0
  60. lsurf/materials/base/spectral_inhomogeneous.py +497 -0
  61. lsurf/materials/implementations/__init__.py +120 -0
  62. lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
  63. lsurf/materials/implementations/duct_atmosphere.py +390 -0
  64. lsurf/materials/implementations/exponential_atmosphere.py +435 -0
  65. lsurf/materials/implementations/gaussian_lens.py +120 -0
  66. lsurf/materials/implementations/interpolated_data.py +123 -0
  67. lsurf/materials/implementations/layered_atmosphere.py +134 -0
  68. lsurf/materials/implementations/linear_gradient.py +109 -0
  69. lsurf/materials/implementations/linsley_atmosphere.py +764 -0
  70. lsurf/materials/implementations/standard_materials.py +126 -0
  71. lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
  72. lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
  73. lsurf/materials/utils/__init__.py +77 -0
  74. lsurf/materials/utils/constants.py +45 -0
  75. lsurf/materials/utils/device_functions.py +117 -0
  76. lsurf/materials/utils/dispersion.py +160 -0
  77. lsurf/materials/utils/factories.py +142 -0
  78. lsurf/propagation/__init__.py +91 -0
  79. lsurf/propagation/detector_gpu.py +67 -0
  80. lsurf/propagation/gpu_device_rays.py +294 -0
  81. lsurf/propagation/kernels/__init__.py +175 -0
  82. lsurf/propagation/kernels/absorption/__init__.py +61 -0
  83. lsurf/propagation/kernels/absorption/grid.py +240 -0
  84. lsurf/propagation/kernels/absorption/simple.py +232 -0
  85. lsurf/propagation/kernels/absorption/spectral.py +410 -0
  86. lsurf/propagation/kernels/detection/__init__.py +64 -0
  87. lsurf/propagation/kernels/detection/protocol.py +102 -0
  88. lsurf/propagation/kernels/detection/spherical.py +255 -0
  89. lsurf/propagation/kernels/device_functions.py +790 -0
  90. lsurf/propagation/kernels/fresnel/__init__.py +64 -0
  91. lsurf/propagation/kernels/fresnel/protocol.py +97 -0
  92. lsurf/propagation/kernels/fresnel/standard.py +258 -0
  93. lsurf/propagation/kernels/intersection/__init__.py +79 -0
  94. lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
  95. lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
  96. lsurf/propagation/kernels/intersection/plane.py +166 -0
  97. lsurf/propagation/kernels/intersection/protocol.py +95 -0
  98. lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
  99. lsurf/propagation/kernels/intersection/sphere.py +190 -0
  100. lsurf/propagation/kernels/propagation/__init__.py +85 -0
  101. lsurf/propagation/kernels/propagation/grid.py +527 -0
  102. lsurf/propagation/kernels/propagation/protocol.py +105 -0
  103. lsurf/propagation/kernels/propagation/simple.py +460 -0
  104. lsurf/propagation/kernels/propagation/spectral.py +875 -0
  105. lsurf/propagation/kernels/registry.py +331 -0
  106. lsurf/propagation/kernels/surface/__init__.py +72 -0
  107. lsurf/propagation/kernels/surface/bisection.py +232 -0
  108. lsurf/propagation/kernels/surface/detection.py +402 -0
  109. lsurf/propagation/kernels/surface/reduction.py +166 -0
  110. lsurf/propagation/propagator_protocol.py +222 -0
  111. lsurf/propagation/propagators/__init__.py +101 -0
  112. lsurf/propagation/propagators/detector_handler.py +354 -0
  113. lsurf/propagation/propagators/factory.py +200 -0
  114. lsurf/propagation/propagators/fresnel_handler.py +305 -0
  115. lsurf/propagation/propagators/gpu_gradient.py +566 -0
  116. lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
  117. lsurf/propagation/propagators/gradient.py +429 -0
  118. lsurf/propagation/propagators/intersection_handler.py +327 -0
  119. lsurf/propagation/propagators/material_propagator.py +398 -0
  120. lsurf/propagation/propagators/signed_distance_handler.py +522 -0
  121. lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
  122. lsurf/propagation/propagators/surface_interaction.py +616 -0
  123. lsurf/propagation/propagators/surface_propagator.py +719 -0
  124. lsurf/py.typed +1 -0
  125. lsurf/simulation/__init__.py +70 -0
  126. lsurf/simulation/config.py +164 -0
  127. lsurf/simulation/orchestrator.py +462 -0
  128. lsurf/simulation/result.py +299 -0
  129. lsurf/simulation/simulation.py +262 -0
  130. lsurf/sources/__init__.py +128 -0
  131. lsurf/sources/base.py +264 -0
  132. lsurf/sources/collimated.py +252 -0
  133. lsurf/sources/custom.py +409 -0
  134. lsurf/sources/diverging.py +228 -0
  135. lsurf/sources/gaussian.py +272 -0
  136. lsurf/sources/parallel_from_positions.py +197 -0
  137. lsurf/sources/point.py +172 -0
  138. lsurf/sources/uniform_diverging.py +258 -0
  139. lsurf/surfaces/__init__.py +184 -0
  140. lsurf/surfaces/cpu/__init__.py +50 -0
  141. lsurf/surfaces/cpu/curved_wave.py +463 -0
  142. lsurf/surfaces/cpu/gerstner_wave.py +381 -0
  143. lsurf/surfaces/cpu/wave_params.py +118 -0
  144. lsurf/surfaces/gpu/__init__.py +72 -0
  145. lsurf/surfaces/gpu/annular_plane.py +453 -0
  146. lsurf/surfaces/gpu/bounded_plane.py +390 -0
  147. lsurf/surfaces/gpu/curved_wave.py +483 -0
  148. lsurf/surfaces/gpu/gerstner_wave.py +377 -0
  149. lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
  150. lsurf/surfaces/gpu/plane.py +299 -0
  151. lsurf/surfaces/gpu/recording_sphere.py +587 -0
  152. lsurf/surfaces/gpu/sphere.py +311 -0
  153. lsurf/surfaces/protocol.py +336 -0
  154. lsurf/surfaces/registry.py +373 -0
  155. lsurf/utilities/__init__.py +175 -0
  156. lsurf/utilities/detector_analysis.py +814 -0
  157. lsurf/utilities/fresnel.py +628 -0
  158. lsurf/utilities/interactions.py +1215 -0
  159. lsurf/utilities/propagation.py +602 -0
  160. lsurf/utilities/ray_data.py +532 -0
  161. lsurf/utilities/recording_sphere.py +745 -0
  162. lsurf/utilities/time_spread.py +463 -0
  163. lsurf/visualization/__init__.py +329 -0
  164. lsurf/visualization/absorption_plots.py +334 -0
  165. lsurf/visualization/atmospheric_plots.py +754 -0
  166. lsurf/visualization/common.py +348 -0
  167. lsurf/visualization/detector_plots.py +1350 -0
  168. lsurf/visualization/detector_sphere_plots.py +1173 -0
  169. lsurf/visualization/fresnel_plots.py +1061 -0
  170. lsurf/visualization/ocean_simulation_plots.py +999 -0
  171. lsurf/visualization/polarization_plots.py +916 -0
  172. lsurf/visualization/raytracing_plots.py +1521 -0
  173. lsurf/visualization/ring_detector_plots.py +1867 -0
  174. lsurf/visualization/time_spread_plots.py +531 -0
  175. lsurf-1.0.0.dist-info/METADATA +381 -0
  176. lsurf-1.0.0.dist-info/RECORD +180 -0
  177. lsurf-1.0.0.dist-info/WHEEL +5 -0
  178. lsurf-1.0.0.dist-info/entry_points.txt +2 -0
  179. lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
  180. lsurf-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1280 @@
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
+ Sphere Visualization - Mollweide Projections and Time Analysis
36
+
37
+ Functions for visualizing ray patterns on spherical detection surfaces
38
+ using Mollweide projections and time distribution analysis.
39
+ """
40
+
41
+ import matplotlib.gridspec as gridspec
42
+ import matplotlib.pyplot as plt
43
+ import numpy as np
44
+ from matplotlib.colors import LogNorm
45
+
46
+ try:
47
+ from astropy import units as u
48
+ from astropy_healpix import HEALPix
49
+
50
+ HAS_HEALPIX = True
51
+ except ImportError:
52
+ HAS_HEALPIX = False
53
+
54
+
55
+ def plot_mollweide_intensity(
56
+ healpix_data,
57
+ output_path: str | None = None,
58
+ log_scale: bool = True,
59
+ cmap: str = "hot",
60
+ title: str | None = None,
61
+ show_aggregated: bool = False,
62
+ dpi: int = 150,
63
+ ) -> plt.Figure:
64
+ """
65
+ Plot intensity distribution on Mollweide projection.
66
+
67
+ Parameters
68
+ ----------
69
+ healpix_data : HEALPixData
70
+ HEALPix-mapped ray data
71
+ output_path : str, optional
72
+ If provided, save figure to this path
73
+ log_scale : bool, optional
74
+ Use logarithmic color scale (default: True)
75
+ cmap : str, optional
76
+ Matplotlib colormap (default: 'hot')
77
+ title : str, optional
78
+ Figure title
79
+ show_aggregated : bool, optional
80
+ If True and aggregated data exists, show per-pixel aggregated intensities
81
+ If False, show individual ray scatter plot (default: False)
82
+ dpi : int, optional
83
+ Figure resolution (default: 150)
84
+
85
+ Returns
86
+ -------
87
+ Figure
88
+ Matplotlib figure object
89
+ """
90
+ fig = plt.figure(figsize=(14, 7))
91
+ ax = fig.add_subplot(111, projection="mollweide")
92
+
93
+ if show_aggregated and healpix_data.aggregated is not None:
94
+ # Plot aggregated per-pixel data
95
+ pixel_ids = healpix_data.aggregated["pixel_ids"]
96
+ intensity_sum = healpix_data.aggregated["intensity_sum"]
97
+
98
+ # Get pixel centers
99
+ hp = HEALPix(nside=healpix_data.nside, order="ring", frame=None)
100
+ lon, lat = hp.healpix_to_lonlat(pixel_ids)
101
+
102
+ # Convert to radians and wrap longitude
103
+ lon_rad = lon.to(u.rad).value
104
+ lat_rad = lat.to(u.rad).value
105
+
106
+ # Wrap longitude to [-π, π] for Mollweide
107
+ lon_rad = np.where(lon_rad > np.pi, lon_rad - 2 * np.pi, lon_rad)
108
+
109
+ # Plot
110
+ if log_scale and np.any(intensity_sum > 0):
111
+ norm = LogNorm(
112
+ vmin=intensity_sum[intensity_sum > 0].min(), vmax=intensity_sum.max()
113
+ )
114
+ scatter = ax.scatter(
115
+ lon_rad, lat_rad, c=intensity_sum, cmap=cmap, s=5, alpha=0.8, norm=norm
116
+ )
117
+ else:
118
+ scatter = ax.scatter(
119
+ lon_rad, lat_rad, c=intensity_sum, cmap=cmap, s=5, alpha=0.8
120
+ )
121
+
122
+ cbar_label = "Summed Intensity per Pixel"
123
+
124
+ else:
125
+ # Plot individual rays
126
+ lon_rad = healpix_data.lon.copy()
127
+ lat_rad = healpix_data.lat.copy()
128
+ intensities = healpix_data.intensities
129
+
130
+ # Wrap longitude to [-π, π]
131
+ lon_rad = np.where(lon_rad > np.pi, lon_rad - 2 * np.pi, lon_rad)
132
+
133
+ # Plot
134
+ if log_scale and np.any(intensities > 0):
135
+ norm = LogNorm(
136
+ vmin=intensities[intensities > 0].min(), vmax=intensities.max()
137
+ )
138
+ scatter = ax.scatter(
139
+ lon_rad, lat_rad, c=intensities, cmap=cmap, s=2, alpha=0.6, norm=norm
140
+ )
141
+ else:
142
+ scatter = ax.scatter(
143
+ lon_rad, lat_rad, c=intensities, cmap=cmap, s=2, alpha=0.6
144
+ )
145
+
146
+ cbar_label = "Ray Intensity"
147
+
148
+ # Colorbar
149
+ cbar = plt.colorbar(scatter, ax=ax, pad=0.1, shrink=0.8)
150
+ if log_scale:
151
+ cbar_label += " (log scale)"
152
+ cbar.set_label(cbar_label, fontsize=12)
153
+
154
+ # Labels
155
+ ax.set_xlabel("Azimuth (longitude)", fontsize=12)
156
+ ax.set_ylabel("Elevation (latitude)", fontsize=12)
157
+
158
+ if title is None:
159
+ title = f"Intensity Distribution on Detection Sphere (N={healpix_data.num_rays:,} rays)"
160
+ ax.set_title(title, fontsize=14, pad=20)
161
+
162
+ ax.grid(True, alpha=0.3)
163
+
164
+ plt.tight_layout()
165
+
166
+ if output_path:
167
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
168
+ print(f"Saved: {output_path}")
169
+
170
+ return fig
171
+
172
+
173
+ def plot_mollweide_time(
174
+ healpix_data,
175
+ output_path: str | None = None,
176
+ cmap: str = "viridis",
177
+ title: str | None = None,
178
+ show_aggregated: bool = False,
179
+ time_units: str = "us",
180
+ dpi: int = 150,
181
+ ) -> plt.Figure:
182
+ """
183
+ Plot arrival time distribution on Mollweide projection.
184
+
185
+ Parameters
186
+ ----------
187
+ healpix_data : HEALPixData
188
+ HEALPix-mapped ray data
189
+ output_path : str, optional
190
+ If provided, save figure to this path
191
+ cmap : str, optional
192
+ Matplotlib colormap (default: 'viridis')
193
+ title : str, optional
194
+ Figure title
195
+ show_aggregated : bool, optional
196
+ If True, show per-pixel weighted mean time (default: False)
197
+ time_units : str, optional
198
+ Time units for display: 'us', 'ns', 's' (default: 'us')
199
+ dpi : int, optional
200
+ Figure resolution (default: 150)
201
+
202
+ Returns
203
+ -------
204
+ Figure
205
+ Matplotlib figure object
206
+ """
207
+ # Convert time units
208
+ unit_scales = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
209
+ unit_labels = {"s": "s", "ms": "ms", "us": "μs", "ns": "ns"}
210
+ scale = unit_scales.get(time_units, 1e6)
211
+ label = unit_labels.get(time_units, "μs")
212
+
213
+ fig = plt.figure(figsize=(14, 7))
214
+ ax = fig.add_subplot(111, projection="mollweide")
215
+
216
+ if show_aggregated and healpix_data.aggregated is not None:
217
+ # Plot aggregated weighted mean time
218
+ pixel_ids = healpix_data.aggregated["pixel_ids"]
219
+ times = healpix_data.aggregated["time_weighted_mean"] * scale
220
+
221
+ # Get pixel centers
222
+ hp = HEALPix(nside=healpix_data.nside, order="ring", frame=None)
223
+ lon, lat = hp.healpix_to_lonlat(pixel_ids)
224
+
225
+ lon_rad = lon.to(u.rad).value
226
+ lat_rad = lat.to(u.rad).value
227
+ lon_rad = np.where(lon_rad > np.pi, lon_rad - 2 * np.pi, lon_rad)
228
+
229
+ scatter = ax.scatter(lon_rad, lat_rad, c=times, cmap=cmap, s=5, alpha=0.8)
230
+ cbar_label = f"Intensity-weighted Mean Time ({label})"
231
+
232
+ else:
233
+ # Plot individual ray times
234
+ lon_rad = healpix_data.lon.copy()
235
+ lat_rad = healpix_data.lat.copy()
236
+ times = healpix_data.times * scale
237
+
238
+ lon_rad = np.where(lon_rad > np.pi, lon_rad - 2 * np.pi, lon_rad)
239
+
240
+ scatter = ax.scatter(lon_rad, lat_rad, c=times, cmap=cmap, s=2, alpha=0.6)
241
+ cbar_label = f"Arrival Time ({label})"
242
+
243
+ # Colorbar
244
+ cbar = plt.colorbar(scatter, ax=ax, pad=0.1, shrink=0.8)
245
+ cbar.set_label(cbar_label, fontsize=12)
246
+
247
+ # Labels
248
+ ax.set_xlabel("Azimuth (longitude)", fontsize=12)
249
+ ax.set_ylabel("Elevation (latitude)", fontsize=12)
250
+
251
+ if title is None:
252
+ title = "Arrival Time Distribution on Detection Sphere"
253
+ ax.set_title(title, fontsize=14, pad=20)
254
+
255
+ ax.grid(True, alpha=0.3)
256
+
257
+ plt.tight_layout()
258
+
259
+ if output_path:
260
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
261
+ print(f"Saved: {output_path}")
262
+
263
+ return fig
264
+
265
+
266
+ def plot_time_distributions(
267
+ healpix_data,
268
+ peak_mask: np.ndarray | None = None,
269
+ time_stats: dict | None = None,
270
+ peak_stats: dict | None = None,
271
+ output_path: str | None = None,
272
+ time_units: str = "us",
273
+ bins: int = 100,
274
+ dpi: int = 150,
275
+ ) -> plt.Figure:
276
+ """
277
+ Plot arrival time distributions comparing all rays vs peak region.
278
+
279
+ Parameters
280
+ ----------
281
+ healpix_data : HEALPixData
282
+ HEALPix-mapped ray data
283
+ peak_mask : ndarray, optional
284
+ Boolean mask for rays in peak region
285
+ time_stats : dict, optional
286
+ Statistics for all rays
287
+ peak_stats : dict, optional
288
+ Statistics for peak region rays
289
+ output_path : str, optional
290
+ If provided, save figure to this path
291
+ time_units : str, optional
292
+ Time units: 'us', 'ns', 's' (default: 'us')
293
+ bins : int, optional
294
+ Number of histogram bins (default: 100)
295
+ dpi : int, optional
296
+ Figure resolution (default: 150)
297
+
298
+ Returns
299
+ -------
300
+ Figure
301
+ Matplotlib figure object
302
+ """
303
+ unit_scales = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
304
+ unit_labels = {"s": "s", "ms": "ms", "us": "μs", "ns": "ns"}
305
+ scale = unit_scales.get(time_units, 1e6)
306
+ label = unit_labels.get(time_units, "μs")
307
+
308
+ fig = plt.figure(figsize=(16, 10))
309
+ gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.3, wspace=0.3)
310
+
311
+ # All times
312
+ all_times = healpix_data.times * scale
313
+ all_intensities = healpix_data.intensities
314
+
315
+ # Peak times (if mask provided)
316
+ if peak_mask is not None and np.any(peak_mask):
317
+ peak_times = healpix_data.times[peak_mask] * scale
318
+ peak_intensities = healpix_data.intensities[peak_mask]
319
+ has_peak = True
320
+ else:
321
+ has_peak = False
322
+
323
+ # =========================================================================
324
+ # 1. All rays histogram (intensity-weighted)
325
+ # =========================================================================
326
+ ax1 = fig.add_subplot(gs[0, 0])
327
+ ax1.hist(
328
+ all_times,
329
+ bins=bins,
330
+ weights=all_intensities,
331
+ color="steelblue",
332
+ alpha=0.7,
333
+ edgecolor="black",
334
+ )
335
+ ax1.set_xlabel(f"Arrival Time ({label})", fontsize=11)
336
+ ax1.set_ylabel("Intensity-weighted Count", fontsize=11)
337
+ ax1.set_title("All Rays - Time Distribution", fontsize=12)
338
+ ax1.grid(True, alpha=0.3)
339
+
340
+ if time_stats:
341
+ stats_text = f"Mean: {time_stats['mean_time']*scale:.2f} {label}\n"
342
+ stats_text += f"Std: {time_stats['std_time']*scale:.2f} {label}\n"
343
+ stats_text += f"FWHM: {time_stats['fwhm_time']*scale:.2f} {label}"
344
+ ax1.text(
345
+ 0.02,
346
+ 0.98,
347
+ stats_text,
348
+ transform=ax1.transAxes,
349
+ verticalalignment="top",
350
+ fontsize=9,
351
+ bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5},
352
+ )
353
+
354
+ # =========================================================================
355
+ # 2. Peak region histogram (if available)
356
+ # =========================================================================
357
+ ax2 = fig.add_subplot(gs[0, 1])
358
+ if has_peak:
359
+ ax2.hist(
360
+ peak_times,
361
+ bins=bins,
362
+ weights=peak_intensities,
363
+ color="coral",
364
+ alpha=0.7,
365
+ edgecolor="black",
366
+ )
367
+ ax2.set_xlabel(f"Arrival Time ({label})", fontsize=11)
368
+ ax2.set_ylabel("Intensity-weighted Count", fontsize=11)
369
+ ax2.set_title("Peak Region - Time Distribution", fontsize=12)
370
+ ax2.grid(True, alpha=0.3)
371
+
372
+ if peak_stats:
373
+ stats_text = f"Mean: {peak_stats['mean_time']*scale:.2f} {label}\n"
374
+ stats_text += f"Std: {peak_stats['std_time']*scale:.2f} {label}\n"
375
+ stats_text += f"FWHM: {peak_stats['fwhm_time']*scale:.2f} {label}"
376
+ ax2.text(
377
+ 0.02,
378
+ 0.98,
379
+ stats_text,
380
+ transform=ax2.transAxes,
381
+ verticalalignment="top",
382
+ fontsize=9,
383
+ bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5},
384
+ )
385
+ else:
386
+ ax2.text(
387
+ 0.5,
388
+ 0.5,
389
+ "No peak region defined",
390
+ transform=ax2.transAxes,
391
+ ha="center",
392
+ va="center",
393
+ fontsize=12,
394
+ )
395
+ ax2.set_title("Peak Region - Time Distribution", fontsize=12)
396
+
397
+ # =========================================================================
398
+ # 3. Comparison overlay
399
+ # =========================================================================
400
+ ax3 = fig.add_subplot(gs[1, 0])
401
+ ax3.hist(
402
+ all_times,
403
+ bins=bins,
404
+ weights=all_intensities,
405
+ color="steelblue",
406
+ alpha=0.5,
407
+ label="All rays",
408
+ edgecolor="black",
409
+ )
410
+ if has_peak:
411
+ ax3.hist(
412
+ peak_times,
413
+ bins=bins,
414
+ weights=peak_intensities,
415
+ color="coral",
416
+ alpha=0.5,
417
+ label="Peak region",
418
+ edgecolor="black",
419
+ )
420
+ ax3.set_xlabel(f"Arrival Time ({label})", fontsize=11)
421
+ ax3.set_ylabel("Intensity-weighted Count", fontsize=11)
422
+ ax3.set_title("Comparison: All vs Peak Region", fontsize=12)
423
+ ax3.legend(fontsize=10)
424
+ ax3.grid(True, alpha=0.3)
425
+
426
+ # =========================================================================
427
+ # 4. Statistics summary
428
+ # =========================================================================
429
+ ax4 = fig.add_subplot(gs[1, 1])
430
+ ax4.axis("off")
431
+
432
+ summary_text = "Time Distribution Statistics\n"
433
+ summary_text += "=" * 50 + "\n\n"
434
+
435
+ if time_stats:
436
+ summary_text += f"ALL RAYS (N={time_stats['num_rays']:,})\n"
437
+ summary_text += "-" * 50 + "\n"
438
+ summary_text += (
439
+ f"Mean time: {time_stats['mean_time']*scale:>10.2f} {label}\n"
440
+ )
441
+ summary_text += (
442
+ f"Median time: {time_stats['median_time']*scale:>10.2f} {label}\n"
443
+ )
444
+ summary_text += (
445
+ f"Std deviation: {time_stats['std_time']*scale:>10.2f} {label}\n"
446
+ )
447
+ summary_text += (
448
+ f"FWHM: {time_stats['fwhm_time']*scale:>10.2f} {label}\n"
449
+ )
450
+ summary_text += (
451
+ f"Time span: {time_stats['time_span']*scale:>10.2f} {label}\n"
452
+ )
453
+ summary_text += f"Weighted mean: {time_stats['weighted_mean_time']*scale:>10.2f} {label}\n\n"
454
+
455
+ if peak_stats and has_peak:
456
+ summary_text += f"PEAK REGION (N={peak_stats['num_rays']:,})\n"
457
+ summary_text += "-" * 50 + "\n"
458
+ summary_text += (
459
+ f"Mean time: {peak_stats['mean_time']*scale:>10.2f} {label}\n"
460
+ )
461
+ summary_text += (
462
+ f"Median time: {peak_stats['median_time']*scale:>10.2f} {label}\n"
463
+ )
464
+ summary_text += (
465
+ f"Std deviation: {peak_stats['std_time']*scale:>10.2f} {label}\n"
466
+ )
467
+ summary_text += (
468
+ f"FWHM: {peak_stats['fwhm_time']*scale:>10.2f} {label}\n"
469
+ )
470
+ summary_text += (
471
+ f"Time span: {peak_stats['time_span']*scale:>10.2f} {label}\n"
472
+ )
473
+ summary_text += (
474
+ f"Weighted mean: {peak_stats['weighted_mean_time']*scale:>10.2f} {label}\n"
475
+ )
476
+
477
+ ax4.text(
478
+ 0.1,
479
+ 0.9,
480
+ summary_text,
481
+ transform=ax4.transAxes,
482
+ verticalalignment="top",
483
+ fontsize=10,
484
+ fontfamily="monospace",
485
+ bbox={"boxstyle": "round", "facecolor": "lightblue", "alpha": 0.3},
486
+ )
487
+
488
+ plt.suptitle("Arrival Time Analysis", fontsize=14, y=0.98)
489
+
490
+ if output_path:
491
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
492
+ print(f"Saved: {output_path}")
493
+
494
+ return fig
495
+
496
+
497
+ def plot_combined_analysis(
498
+ healpix_data,
499
+ peak_mask: np.ndarray | None = None,
500
+ time_stats: dict | None = None,
501
+ peak_stats: dict | None = None,
502
+ output_path: str | None = None,
503
+ log_scale: bool = True,
504
+ time_units: str = "us",
505
+ dpi: int = 150,
506
+ ) -> plt.Figure:
507
+ """
508
+ Create comprehensive multi-panel analysis figure.
509
+
510
+ Parameters
511
+ ----------
512
+ healpix_data : HEALPixData
513
+ HEALPix-mapped ray data
514
+ peak_mask : ndarray, optional
515
+ Boolean mask for peak region rays
516
+ time_stats : dict, optional
517
+ Statistics for all rays
518
+ peak_stats : dict, optional
519
+ Statistics for peak region
520
+ output_path : str, optional
521
+ If provided, save figure to this path
522
+ log_scale : bool, optional
523
+ Use log scale for intensity (default: True)
524
+ time_units : str, optional
525
+ Time units (default: 'us')
526
+ dpi : int, optional
527
+ Figure resolution (default: 150)
528
+
529
+ Returns
530
+ -------
531
+ Figure
532
+ Matplotlib figure object
533
+ """
534
+ unit_scales = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
535
+ unit_labels = {"s": "s", "ms": "ms", "us": "μs", "ns": "ns"}
536
+ scale = unit_scales.get(time_units, 1e6)
537
+ label = unit_labels.get(time_units, "μs")
538
+
539
+ fig = plt.figure(figsize=(18, 12))
540
+ gs = gridspec.GridSpec(
541
+ 3, 2, figure=fig, hspace=0.35, wspace=0.25, height_ratios=[1.2, 1, 1]
542
+ )
543
+
544
+ # =========================================================================
545
+ # 1. Intensity map (Mollweide)
546
+ # =========================================================================
547
+ ax1 = fig.add_subplot(gs[0, :], projection="mollweide")
548
+
549
+ lon_rad = healpix_data.lon.copy()
550
+ lat_rad = healpix_data.lat.copy()
551
+ intensities = healpix_data.intensities
552
+
553
+ lon_rad = np.where(lon_rad > np.pi, lon_rad - 2 * np.pi, lon_rad)
554
+
555
+ if log_scale and np.any(intensities > 0):
556
+ norm = LogNorm(vmin=intensities[intensities > 0].min(), vmax=intensities.max())
557
+ scatter1 = ax1.scatter(
558
+ lon_rad, lat_rad, c=intensities, cmap="hot", s=2, alpha=0.6, norm=norm
559
+ )
560
+ cbar_label = "Ray Intensity (log scale)"
561
+ else:
562
+ scatter1 = ax1.scatter(
563
+ lon_rad, lat_rad, c=intensities, cmap="hot", s=2, alpha=0.6
564
+ )
565
+ cbar_label = "Ray Intensity"
566
+
567
+ cbar1 = plt.colorbar(scatter1, ax=ax1, pad=0.05, shrink=0.6, aspect=20)
568
+ cbar1.set_label(cbar_label, fontsize=10)
569
+
570
+ ax1.set_xlabel("Azimuth", fontsize=11)
571
+ ax1.set_ylabel("Elevation", fontsize=11)
572
+ ax1.set_title(
573
+ f"Intensity Distribution (N={healpix_data.num_rays:,} rays)",
574
+ fontsize=12,
575
+ pad=15,
576
+ )
577
+ ax1.grid(True, alpha=0.3)
578
+
579
+ # =========================================================================
580
+ # 2. Time map (Mollweide)
581
+ # =========================================================================
582
+ ax2 = fig.add_subplot(gs[1, :], projection="mollweide")
583
+
584
+ times = healpix_data.times * scale
585
+ scatter2 = ax2.scatter(lon_rad, lat_rad, c=times, cmap="viridis", s=2, alpha=0.6)
586
+
587
+ cbar2 = plt.colorbar(scatter2, ax=ax2, pad=0.05, shrink=0.6, aspect=20)
588
+ cbar2.set_label(f"Arrival Time ({label})", fontsize=10)
589
+
590
+ ax2.set_xlabel("Azimuth", fontsize=11)
591
+ ax2.set_ylabel("Elevation", fontsize=11)
592
+ ax2.set_title("Arrival Time Distribution", fontsize=12, pad=15)
593
+ ax2.grid(True, alpha=0.3)
594
+
595
+ # =========================================================================
596
+ # 3. Time histogram - All rays
597
+ # =========================================================================
598
+ ax3 = fig.add_subplot(gs[2, 0])
599
+
600
+ all_times = healpix_data.times * scale
601
+ all_intensities = healpix_data.intensities
602
+
603
+ ax3.hist(
604
+ all_times,
605
+ bins=80,
606
+ weights=all_intensities,
607
+ color="steelblue",
608
+ alpha=0.7,
609
+ edgecolor="black",
610
+ )
611
+ ax3.set_xlabel(f"Arrival Time ({label})", fontsize=10)
612
+ ax3.set_ylabel("Intensity-weighted Count", fontsize=10)
613
+ ax3.set_title("All Rays - Time Distribution", fontsize=11)
614
+ ax3.grid(True, alpha=0.3)
615
+
616
+ # =========================================================================
617
+ # 4. Time histogram - Peak region
618
+ # =========================================================================
619
+ ax4 = fig.add_subplot(gs[2, 1])
620
+
621
+ if peak_mask is not None and np.any(peak_mask):
622
+ peak_times = healpix_data.times[peak_mask] * scale
623
+ peak_intensities = healpix_data.intensities[peak_mask]
624
+
625
+ ax4.hist(
626
+ peak_times,
627
+ bins=80,
628
+ weights=peak_intensities,
629
+ color="coral",
630
+ alpha=0.7,
631
+ edgecolor="black",
632
+ )
633
+ ax4.set_xlabel(f"Arrival Time ({label})", fontsize=10)
634
+ ax4.set_ylabel("Intensity-weighted Count", fontsize=10)
635
+ ax4.set_title("Peak Region - Time Distribution", fontsize=11)
636
+ ax4.grid(True, alpha=0.3)
637
+
638
+ if peak_stats:
639
+ stats_text = f"N={peak_stats['num_rays']:,}\n"
640
+ stats_text += f"Mean: {peak_stats['mean_time']*scale:.2f} {label}\n"
641
+ stats_text += f"FWHM: {peak_stats['fwhm_time']*scale:.2f} {label}"
642
+ ax4.text(
643
+ 0.98,
644
+ 0.98,
645
+ stats_text,
646
+ transform=ax4.transAxes,
647
+ verticalalignment="top",
648
+ horizontalalignment="right",
649
+ fontsize=9,
650
+ bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5},
651
+ )
652
+ else:
653
+ ax4.text(
654
+ 0.5,
655
+ 0.5,
656
+ "No peak region defined",
657
+ transform=ax4.transAxes,
658
+ ha="center",
659
+ va="center",
660
+ fontsize=11,
661
+ )
662
+ ax4.set_title("Peak Region - Time Distribution", fontsize=11)
663
+
664
+ plt.suptitle("Sphere Intersection Pattern Analysis", fontsize=16, y=0.995)
665
+
666
+ if output_path:
667
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
668
+ print(f"Saved: {output_path}")
669
+
670
+ return fig
671
+
672
+
673
+ def plot_2d_intensity(
674
+ healpix_data,
675
+ output_path: str | None = None,
676
+ log_scale: bool = True,
677
+ cmap: str = "hot",
678
+ title: str | None = None,
679
+ plot_type: str = "profile",
680
+ gridsize: int = 50,
681
+ dpi: int = 150,
682
+ ) -> plt.Figure:
683
+ """
684
+ Plot intensity distribution vs elevation angle (1D profile).
685
+
686
+ Shows intensity as a function of elevation angle, suitable for
687
+ tightly clustered ray patterns.
688
+
689
+ Parameters
690
+ ----------
691
+ healpix_data : HEALPixData
692
+ HEALPix-mapped ray data
693
+ output_path : str, optional
694
+ If provided, save figure to this path
695
+ log_scale : bool, optional
696
+ Use logarithmic y-axis scale (default: True)
697
+ cmap : str, optional
698
+ Matplotlib colormap (default: 'hot')
699
+ title : str, optional
700
+ Figure title
701
+ plot_type : str, optional
702
+ 'profile' for elevation profile, 'hexbin' for 2D, 'scatter' for 2D scatter (default: 'profile')
703
+ gridsize : int, optional
704
+ Number of bins for elevation profile (default: 50)
705
+ dpi : int, optional
706
+ Figure resolution (default: 150)
707
+
708
+ Returns
709
+ -------
710
+ Figure
711
+ Matplotlib figure object
712
+ """
713
+ fig, ax = plt.subplots(figsize=(12, 8))
714
+
715
+ # Convert to degrees for easier reading
716
+ elevation_deg = np.degrees(healpix_data.lat)
717
+ intensities = healpix_data.intensities
718
+
719
+ if plot_type == "profile":
720
+ # 1D profile: elevation angle vs intensity
721
+ # Bin by elevation and sum intensities
722
+ hist, bin_edges = np.histogram(
723
+ elevation_deg, bins=gridsize, weights=intensities
724
+ )
725
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
726
+
727
+ ax.plot(bin_centers, hist, "o-", color="steelblue", linewidth=2, markersize=4)
728
+ ax.fill_between(bin_centers, 0, hist, alpha=0.3, color="steelblue")
729
+
730
+ ax.set_xlabel("Elevation (degrees)", fontsize=12)
731
+ ax.set_ylabel("Intensity", fontsize=12)
732
+
733
+ if log_scale and np.any(hist > 0):
734
+ ax.set_yscale("log")
735
+
736
+ ax.grid(True, alpha=0.3)
737
+
738
+ else:
739
+ # Fall back to 2D plots
740
+ azimuth_deg = np.degrees(healpix_data.lon)
741
+
742
+ if plot_type == "hexbin":
743
+ # Hexagonal binning
744
+ if log_scale and np.any(intensities > 0):
745
+ hb = ax.hexbin(
746
+ azimuth_deg,
747
+ elevation_deg,
748
+ C=intensities,
749
+ gridsize=gridsize,
750
+ cmap=cmap,
751
+ reduce_C_function=np.sum,
752
+ norm=LogNorm(
753
+ vmin=intensities[intensities > 0].min(),
754
+ vmax=intensities.max(),
755
+ ),
756
+ mincnt=1,
757
+ )
758
+ else:
759
+ hb = ax.hexbin(
760
+ azimuth_deg,
761
+ elevation_deg,
762
+ C=intensities,
763
+ gridsize=gridsize,
764
+ cmap=cmap,
765
+ reduce_C_function=np.sum,
766
+ mincnt=1,
767
+ )
768
+
769
+ cbar = plt.colorbar(hb, ax=ax)
770
+ cbar_label = "Summed Intensity per Bin"
771
+ if log_scale:
772
+ cbar_label += " (log scale)"
773
+ cbar.set_label(cbar_label, fontsize=12)
774
+
775
+ ax.set_xlabel("Azimuth (degrees)", fontsize=12)
776
+ ax.set_ylabel("Elevation (degrees)", fontsize=12)
777
+
778
+ else:
779
+ # Scatter plot
780
+ if log_scale and np.any(intensities > 0):
781
+ norm = LogNorm(
782
+ vmin=intensities[intensities > 0].min(), vmax=intensities.max()
783
+ )
784
+ scatter = ax.scatter(
785
+ azimuth_deg,
786
+ elevation_deg,
787
+ c=intensities,
788
+ cmap=cmap,
789
+ s=5,
790
+ alpha=0.6,
791
+ norm=norm,
792
+ )
793
+ else:
794
+ scatter = ax.scatter(
795
+ azimuth_deg,
796
+ elevation_deg,
797
+ c=intensities,
798
+ cmap=cmap,
799
+ s=5,
800
+ alpha=0.6,
801
+ )
802
+
803
+ cbar = plt.colorbar(scatter, ax=ax)
804
+ cbar_label = "Ray Intensity"
805
+ if log_scale:
806
+ cbar_label += " (log scale)"
807
+ cbar.set_label(cbar_label, fontsize=12)
808
+
809
+ ax.set_xlabel("Azimuth (degrees)", fontsize=12)
810
+ ax.set_ylabel("Elevation (degrees)", fontsize=12)
811
+
812
+ if title is None:
813
+ title = f"Intensity Distribution (N={healpix_data.num_rays:,} rays)"
814
+ ax.set_title(title, fontsize=14)
815
+
816
+ plt.tight_layout()
817
+
818
+ if output_path:
819
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
820
+ print(f"Saved: {output_path}")
821
+
822
+ return fig
823
+
824
+
825
+ def plot_viewing_angle_intensity(
826
+ healpix_data,
827
+ output_path: str | None = None,
828
+ log_scale: bool = True,
829
+ title: str | None = None,
830
+ gridsize: int = 50,
831
+ dpi: int = 150,
832
+ ) -> plt.Figure:
833
+ """
834
+ Plot intensity vs viewing angle from horizontal at (0,0,0).
835
+
836
+ Shows intensity as a function of viewing angle from horizontal plane
837
+ at the origin (Earth surface reference point). This is the geometric
838
+ angle to the intersection point, not the ray direction angle.
839
+
840
+ Parameters
841
+ ----------
842
+ healpix_data : HEALPixData
843
+ HEALPix-mapped ray data with viewing_angle attribute
844
+ output_path : str, optional
845
+ If provided, save figure to this path
846
+ log_scale : bool, optional
847
+ Use logarithmic y-axis scale (default: True)
848
+ title : str, optional
849
+ Figure title
850
+ gridsize : int, optional
851
+ Number of bins for angle profile (default: 50)
852
+ dpi : int, optional
853
+ Figure resolution (default: 150)
854
+
855
+ Returns
856
+ -------
857
+ Figure
858
+ Matplotlib figure object
859
+ """
860
+ if healpix_data.viewing_angle is None:
861
+ raise ValueError("HEALPixData does not have viewing_angle computed")
862
+
863
+ fig, ax = plt.subplots(figsize=(12, 8))
864
+
865
+ # Convert to degrees for easier reading
866
+ viewing_angle_deg = np.degrees(healpix_data.viewing_angle)
867
+ intensities = healpix_data.intensities
868
+
869
+ # Bin by viewing angle and sum intensities
870
+ hist, bin_edges = np.histogram(
871
+ viewing_angle_deg, bins=gridsize, weights=intensities
872
+ )
873
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
874
+
875
+ ax.plot(bin_centers, hist, "o-", color="steelblue", linewidth=2, markersize=4)
876
+ ax.fill_between(bin_centers, 0, hist, alpha=0.3, color="steelblue")
877
+
878
+ ax.set_xlabel("Viewing Angle from Horizontal (degrees)", fontsize=12)
879
+ ax.set_ylabel("Intensity (W)", fontsize=12)
880
+
881
+ if log_scale and np.any(hist > 0):
882
+ ax.set_yscale("log")
883
+
884
+ ax.grid(True, alpha=0.3)
885
+
886
+ if title is None:
887
+ title = f"Intensity vs Viewing Angle (N={healpix_data.num_rays:,} rays)"
888
+ ax.set_title(title, fontsize=14)
889
+
890
+ plt.tight_layout()
891
+
892
+ if output_path:
893
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
894
+ print(f"Saved: {output_path}")
895
+
896
+ return fig
897
+
898
+
899
+ def plot_ray_direction_intensity(
900
+ healpix_data,
901
+ output_path: str | None = None,
902
+ log_scale: bool = True,
903
+ title: str | None = None,
904
+ gridsize: int = 50,
905
+ dpi: int = 150,
906
+ ) -> plt.Figure:
907
+ """
908
+ Plot intensity vs ray direction elevation angle.
909
+
910
+ Shows intensity as a function of the elevation angle of the ray
911
+ direction vectors themselves (angle above horizontal).
912
+
913
+ Parameters
914
+ ----------
915
+ healpix_data : HEALPixData
916
+ HEALPix-mapped ray data with ray_elevation attribute
917
+ output_path : str, optional
918
+ If provided, save figure to this path
919
+ log_scale : bool, optional
920
+ Use logarithmic y-axis scale (default: True)
921
+ title : str, optional
922
+ Figure title
923
+ gridsize : int, optional
924
+ Number of bins for angle profile (default: 50)
925
+ dpi : int, optional
926
+ Figure resolution (default: 150)
927
+
928
+ Returns
929
+ -------
930
+ Figure
931
+ Matplotlib figure object
932
+ """
933
+ if healpix_data.ray_elevation is None:
934
+ raise ValueError("HEALPixData does not have ray_elevation computed")
935
+
936
+ fig, ax = plt.subplots(figsize=(12, 8))
937
+
938
+ # Convert to degrees for easier reading
939
+ ray_elevation_deg = np.degrees(healpix_data.ray_elevation)
940
+ intensities = healpix_data.intensities
941
+
942
+ # Bin by ray direction elevation and sum intensities
943
+ hist, bin_edges = np.histogram(
944
+ ray_elevation_deg, bins=gridsize, weights=intensities
945
+ )
946
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
947
+
948
+ ax.plot(bin_centers, hist, "o-", color="darkgreen", linewidth=2, markersize=4)
949
+ ax.fill_between(bin_centers, 0, hist, alpha=0.3, color="darkgreen")
950
+
951
+ ax.set_xlabel("Ray Direction Elevation from Horizontal (degrees)", fontsize=12)
952
+ ax.set_ylabel("Intensity (W)", fontsize=12)
953
+
954
+ if log_scale and np.any(hist > 0):
955
+ ax.set_yscale("log")
956
+
957
+ ax.grid(True, alpha=0.3)
958
+
959
+ if title is None:
960
+ title = f"Intensity vs Ray Direction Angle (N={healpix_data.num_rays:,} rays)"
961
+ ax.set_title(title, fontsize=14)
962
+
963
+ plt.tight_layout()
964
+
965
+ if output_path:
966
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
967
+ print(f"Saved: {output_path}")
968
+
969
+ return fig
970
+
971
+
972
+ def plot_2d_time(
973
+ healpix_data,
974
+ output_path: str | None = None,
975
+ cmap: str = "viridis",
976
+ title: str | None = None,
977
+ plot_type: str = "hexbin",
978
+ gridsize: int = 50,
979
+ time_units: str = "us",
980
+ dpi: int = 150,
981
+ ) -> plt.Figure:
982
+ """
983
+ Plot arrival time distribution as simple 2D plot (azimuth vs elevation).
984
+
985
+ Parameters
986
+ ----------
987
+ healpix_data : HEALPixData
988
+ HEALPix-mapped ray data
989
+ output_path : str, optional
990
+ If provided, save figure to this path
991
+ cmap : str, optional
992
+ Matplotlib colormap (default: 'viridis')
993
+ title : str, optional
994
+ Figure title
995
+ plot_type : str, optional
996
+ 'hexbin' for hexagonal binning, 'scatter' for scatter plot (default: 'hexbin')
997
+ gridsize : int, optional
998
+ Grid size for hexbin (default: 50)
999
+ time_units : str, optional
1000
+ Time units: 'us', 'ns', 's' (default: 'us')
1001
+ dpi : int, optional
1002
+ Figure resolution (default: 150)
1003
+
1004
+ Returns
1005
+ -------
1006
+ Figure
1007
+ Matplotlib figure object
1008
+ """
1009
+ unit_scales = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
1010
+ unit_labels = {"s": "s", "ms": "ms", "us": "μs", "ns": "ns"}
1011
+ scale = unit_scales.get(time_units, 1e6)
1012
+ label = unit_labels.get(time_units, "μs")
1013
+
1014
+ fig, ax = plt.subplots(figsize=(12, 8))
1015
+
1016
+ # Convert to degrees
1017
+ azimuth_deg = np.degrees(healpix_data.lon)
1018
+ elevation_deg = np.degrees(healpix_data.lat)
1019
+ times = healpix_data.times * scale
1020
+
1021
+ if plot_type == "hexbin":
1022
+ # Hexagonal binning with intensity-weighted mean time
1023
+ hb = ax.hexbin(
1024
+ azimuth_deg,
1025
+ elevation_deg,
1026
+ C=times,
1027
+ gridsize=gridsize,
1028
+ cmap=cmap,
1029
+ reduce_C_function=np.mean,
1030
+ mincnt=1,
1031
+ )
1032
+ cbar_label = f"Mean Arrival Time ({label})"
1033
+ else:
1034
+ # Scatter plot
1035
+ scatter = ax.scatter(
1036
+ azimuth_deg, elevation_deg, c=times, cmap=cmap, s=5, alpha=0.6
1037
+ )
1038
+ hb = scatter
1039
+ cbar_label = f"Arrival Time ({label})"
1040
+
1041
+ cbar = plt.colorbar(hb, ax=ax)
1042
+ cbar.set_label(cbar_label, fontsize=12)
1043
+
1044
+ ax.set_xlabel("Azimuth (degrees)", fontsize=12)
1045
+ ax.set_ylabel("Elevation (degrees)", fontsize=12)
1046
+
1047
+ if title is None:
1048
+ title = f"Arrival Time Distribution (N={healpix_data.num_rays:,} rays)"
1049
+ ax.set_title(title, fontsize=14)
1050
+
1051
+ ax.grid(True, alpha=0.3)
1052
+
1053
+ plt.tight_layout()
1054
+
1055
+ if output_path:
1056
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
1057
+ print(f"Saved: {output_path}")
1058
+
1059
+ return fig
1060
+
1061
+
1062
+ def plot_2d_combined(
1063
+ healpix_data,
1064
+ peak_mask: np.ndarray | None = None,
1065
+ time_stats: dict | None = None,
1066
+ peak_stats: dict | None = None,
1067
+ output_path: str | None = None,
1068
+ log_scale: bool = True,
1069
+ time_units: str = "us",
1070
+ plot_type: str = "hexbin",
1071
+ gridsize: int = 50,
1072
+ dpi: int = 150,
1073
+ ) -> plt.Figure:
1074
+ """
1075
+ Create 2D combined analysis figure (simpler than Mollweide for localized patterns).
1076
+
1077
+ Parameters
1078
+ ----------
1079
+ healpix_data : HEALPixData
1080
+ HEALPix-mapped ray data
1081
+ peak_mask : ndarray, optional
1082
+ Boolean mask for peak region rays
1083
+ time_stats : dict, optional
1084
+ Statistics for all rays
1085
+ peak_stats : dict, optional
1086
+ Statistics for peak region
1087
+ output_path : str, optional
1088
+ If provided, save figure to this path
1089
+ log_scale : bool, optional
1090
+ Use log scale for intensity (default: True)
1091
+ time_units : str, optional
1092
+ Time units (default: 'us')
1093
+ plot_type : str, optional
1094
+ 'hexbin' or 'scatter' (default: 'hexbin')
1095
+ gridsize : int, optional
1096
+ Grid size for hexbin (default: 50)
1097
+ dpi : int, optional
1098
+ Figure resolution (default: 150)
1099
+
1100
+ Returns
1101
+ -------
1102
+ Figure
1103
+ Matplotlib figure object
1104
+ """
1105
+ unit_scales = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
1106
+ unit_labels = {"s": "s", "ms": "ms", "us": "μs", "ns": "ns"}
1107
+ scale = unit_scales.get(time_units, 1e6)
1108
+ label = unit_labels.get(time_units, "μs")
1109
+
1110
+ fig = plt.figure(figsize=(16, 10))
1111
+ gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.3, wspace=0.3)
1112
+
1113
+ # Convert to degrees
1114
+ azimuth_deg = np.degrees(healpix_data.lon)
1115
+ elevation_deg = np.degrees(healpix_data.lat)
1116
+ intensities = healpix_data.intensities
1117
+ times = healpix_data.times * scale
1118
+
1119
+ # =========================================================================
1120
+ # 1. Intensity map (2D)
1121
+ # =========================================================================
1122
+ ax1 = fig.add_subplot(gs[0, 0])
1123
+
1124
+ if plot_type == "hexbin":
1125
+ if log_scale and np.any(intensities > 0):
1126
+ hb1 = ax1.hexbin(
1127
+ azimuth_deg,
1128
+ elevation_deg,
1129
+ C=intensities,
1130
+ gridsize=gridsize,
1131
+ cmap="hot",
1132
+ reduce_C_function=np.sum,
1133
+ norm=LogNorm(
1134
+ vmin=intensities[intensities > 0].min(), vmax=intensities.max()
1135
+ ),
1136
+ mincnt=1,
1137
+ )
1138
+ else:
1139
+ hb1 = ax1.hexbin(
1140
+ azimuth_deg,
1141
+ elevation_deg,
1142
+ C=intensities,
1143
+ gridsize=gridsize,
1144
+ cmap="hot",
1145
+ reduce_C_function=np.sum,
1146
+ mincnt=1,
1147
+ )
1148
+ cbar1 = plt.colorbar(hb1, ax=ax1)
1149
+ cbar_label = "Summed Intensity"
1150
+ else:
1151
+ if log_scale and np.any(intensities > 0):
1152
+ norm = LogNorm(
1153
+ vmin=intensities[intensities > 0].min(), vmax=intensities.max()
1154
+ )
1155
+ sc1 = ax1.scatter(
1156
+ azimuth_deg,
1157
+ elevation_deg,
1158
+ c=intensities,
1159
+ cmap="hot",
1160
+ s=2,
1161
+ alpha=0.6,
1162
+ norm=norm,
1163
+ )
1164
+ else:
1165
+ sc1 = ax1.scatter(
1166
+ azimuth_deg, elevation_deg, c=intensities, cmap="hot", s=2, alpha=0.6
1167
+ )
1168
+ cbar1 = plt.colorbar(sc1, ax=ax1)
1169
+ cbar_label = "Intensity"
1170
+
1171
+ if log_scale:
1172
+ cbar_label += " (log)"
1173
+ cbar1.set_label(cbar_label, fontsize=10)
1174
+
1175
+ ax1.set_xlabel("Azimuth (°)", fontsize=11)
1176
+ ax1.set_ylabel("Elevation (°)", fontsize=11)
1177
+ ax1.set_title(f"Intensity Distribution (N={healpix_data.num_rays:,})", fontsize=12)
1178
+ ax1.grid(True, alpha=0.3)
1179
+
1180
+ # =========================================================================
1181
+ # 2. Time map (2D)
1182
+ # =========================================================================
1183
+ ax2 = fig.add_subplot(gs[0, 1])
1184
+
1185
+ if plot_type == "hexbin":
1186
+ hb2 = ax2.hexbin(
1187
+ azimuth_deg,
1188
+ elevation_deg,
1189
+ C=times,
1190
+ gridsize=gridsize,
1191
+ cmap="viridis",
1192
+ reduce_C_function=np.mean,
1193
+ mincnt=1,
1194
+ )
1195
+ else:
1196
+ hb2 = ax2.scatter(
1197
+ azimuth_deg, elevation_deg, c=times, cmap="viridis", s=2, alpha=0.6
1198
+ )
1199
+
1200
+ cbar2 = plt.colorbar(hb2, ax=ax2)
1201
+ cbar2.set_label(f"Arrival Time ({label})", fontsize=10)
1202
+
1203
+ ax2.set_xlabel("Azimuth (°)", fontsize=11)
1204
+ ax2.set_ylabel("Elevation (°)", fontsize=11)
1205
+ ax2.set_title("Arrival Time Distribution", fontsize=12)
1206
+ ax2.grid(True, alpha=0.3)
1207
+
1208
+ # =========================================================================
1209
+ # 3. Time histogram - All rays
1210
+ # =========================================================================
1211
+ ax3 = fig.add_subplot(gs[1, 0])
1212
+
1213
+ ax3.hist(
1214
+ times,
1215
+ bins=80,
1216
+ weights=intensities,
1217
+ color="steelblue",
1218
+ alpha=0.7,
1219
+ edgecolor="black",
1220
+ )
1221
+ ax3.set_xlabel(f"Arrival Time ({label})", fontsize=10)
1222
+ ax3.set_ylabel("Intensity-weighted Count", fontsize=10)
1223
+ ax3.set_title("All Rays - Time Distribution", fontsize=11)
1224
+ ax3.grid(True, alpha=0.3)
1225
+
1226
+ # =========================================================================
1227
+ # 4. Time histogram - Peak region
1228
+ # =========================================================================
1229
+ ax4 = fig.add_subplot(gs[1, 1])
1230
+
1231
+ if peak_mask is not None and np.any(peak_mask):
1232
+ peak_times = healpix_data.times[peak_mask] * scale
1233
+ peak_intensities = healpix_data.intensities[peak_mask]
1234
+
1235
+ ax4.hist(
1236
+ peak_times,
1237
+ bins=80,
1238
+ weights=peak_intensities,
1239
+ color="coral",
1240
+ alpha=0.7,
1241
+ edgecolor="black",
1242
+ )
1243
+ ax4.set_xlabel(f"Arrival Time ({label})", fontsize=10)
1244
+ ax4.set_ylabel("Intensity-weighted Count", fontsize=10)
1245
+ ax4.set_title("Peak Region - Time Distribution", fontsize=11)
1246
+ ax4.grid(True, alpha=0.3)
1247
+
1248
+ if peak_stats:
1249
+ stats_text = f"N={peak_stats['num_rays']:,}\n"
1250
+ stats_text += f"Mean: {peak_stats['mean_time']*scale:.2f} {label}\n"
1251
+ stats_text += f"FWHM: {peak_stats['fwhm_time']*scale:.2f} {label}"
1252
+ ax4.text(
1253
+ 0.98,
1254
+ 0.98,
1255
+ stats_text,
1256
+ transform=ax4.transAxes,
1257
+ verticalalignment="top",
1258
+ horizontalalignment="right",
1259
+ fontsize=9,
1260
+ bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5},
1261
+ )
1262
+ else:
1263
+ ax4.text(
1264
+ 0.5,
1265
+ 0.5,
1266
+ "No peak region defined",
1267
+ transform=ax4.transAxes,
1268
+ ha="center",
1269
+ va="center",
1270
+ fontsize=11,
1271
+ )
1272
+ ax4.set_title("Peak Region - Time Distribution", fontsize=11)
1273
+
1274
+ plt.suptitle("Sphere Intersection Pattern - 2D Analysis", fontsize=14, y=0.995)
1275
+
1276
+ if output_path:
1277
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
1278
+ print(f"Saved: {output_path}")
1279
+
1280
+ return fig