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,1350 @@
1
+ # The Clear BSD License
2
+ #
3
+ # Copyright (c) 2026 Tobias Heibges
4
+ # All rights reserved.
5
+ #
6
+ # Redistribution and use in source and binary forms, with or without
7
+ # modification, are permitted (subject to the limitations in the disclaimer
8
+ # below) provided that the following conditions are met:
9
+ #
10
+ # * Redistributions of source code must retain the above copyright notice,
11
+ # this list of conditions and the following disclaimer.
12
+ #
13
+ # * Redistributions in binary form must reproduce the above copyright
14
+ # notice, this list of conditions and the following disclaimer in the
15
+ # documentation and/or other materials provided with the distribution.
16
+ #
17
+ # * Neither the name of the copyright holder nor the names of its
18
+ # contributors may be used to endorse or promote products derived from this
19
+ # software without specific prior written permission.
20
+ #
21
+ # NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
22
+ # THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
23
+ # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
24
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
25
+ # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
26
+ # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
27
+ # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
28
+ # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
29
+ # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
30
+ # IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
31
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32
+ # POSSIBILITY OF SUCH DAMAGE.
33
+
34
+ """
35
+ Detector Visualization - Individual Axis Functions
36
+
37
+ Functions for plotting detector-related data: beam profiles, wavelength distributions,
38
+ detection counts, arrival times, and scan results.
39
+ Each function draws on a single axis, enabling flexible composition.
40
+ """
41
+
42
+ from typing import TYPE_CHECKING, Any
43
+
44
+ if TYPE_CHECKING:
45
+ from ..utilities.ray_data import RayBatch, RayStatistics
46
+ from ..surfaces import Surface
47
+
48
+ import matplotlib.pyplot as plt
49
+ import numpy as np
50
+ from matplotlib.axes import Axes
51
+ from matplotlib.figure import Figure
52
+
53
+ from .common import (
54
+ add_colorbar,
55
+ save_figure,
56
+ setup_axis_grid,
57
+ )
58
+
59
+ # =============================================================================
60
+ # Beam Profile Functions
61
+ # =============================================================================
62
+
63
+
64
+ def plot_beam_slice(
65
+ ax: Axes,
66
+ rays: "RayBatch",
67
+ axis: str = "z",
68
+ slice_value: float = 0.0,
69
+ slice_width: float = 0.1,
70
+ color_by: str = "intensity",
71
+ point_size: float = 20,
72
+ alpha: float = 0.6,
73
+ show_colorbar: bool = True,
74
+ ) -> Any | None:
75
+ """
76
+ Plot beam profile at a specific slice along propagation axis.
77
+
78
+ Parameters
79
+ ----------
80
+ ax : Axes
81
+ Matplotlib axes.
82
+ rays : RayBatch
83
+ Ray batch.
84
+ axis : str
85
+ Propagation axis: 'x', 'y', or 'z'.
86
+ slice_value : float
87
+ Position along axis to slice.
88
+ slice_width : float
89
+ Width of slice.
90
+ color_by : str
91
+ Color by: 'intensity', 'wavelength'.
92
+ point_size : float
93
+ Scatter point size.
94
+ alpha : float
95
+ Transparency.
96
+ show_colorbar : bool
97
+ Whether to add colorbar.
98
+
99
+ Returns
100
+ -------
101
+ scatter or None
102
+ ScalarMappable for external colorbar.
103
+ """
104
+ active_mask = rays.active
105
+ positions = rays.positions[active_mask]
106
+ intensities = rays.intensities[active_mask]
107
+ wavelengths = rays.wavelengths[active_mask] * 1e9
108
+
109
+ # Determine axis indices
110
+ axis_map = {"x": 0, "y": 1, "z": 2}
111
+ if axis.lower() not in axis_map:
112
+ raise ValueError(f"Invalid axis: {axis}. Use 'x', 'y', or 'z'")
113
+
114
+ axis_idx = axis_map[axis.lower()]
115
+ perp_idx1 = (axis_idx + 1) % 3
116
+ perp_idx2 = (axis_idx + 2) % 3
117
+ axis_labels = ["X", "Y", "Z"]
118
+
119
+ # Select rays in slice
120
+ axis_pos = positions[:, axis_idx]
121
+ mask = np.abs(axis_pos - slice_value) <= slice_width / 2
122
+
123
+ if np.sum(mask) == 0:
124
+ ax.text(
125
+ 0.5,
126
+ 0.5,
127
+ "No rays in slice",
128
+ ha="center",
129
+ va="center",
130
+ transform=ax.transAxes,
131
+ )
132
+ return None
133
+
134
+ x = positions[mask, perp_idx1]
135
+ y = positions[mask, perp_idx2]
136
+
137
+ if color_by == "intensity":
138
+ c = intensities[mask]
139
+ cmap = "hot"
140
+ clabel = "Intensity"
141
+ else:
142
+ c = wavelengths[mask]
143
+ cmap = "rainbow"
144
+ clabel = "Wavelength (nm)"
145
+
146
+ scatter = ax.scatter(x, y, c=c, s=point_size, cmap=cmap, alpha=alpha)
147
+ ax.set_xlabel(f"{axis_labels[perp_idx1]} (m)")
148
+ ax.set_ylabel(f"{axis_labels[perp_idx2]} (m)")
149
+ ax.set_title(f"{axis.upper()}={slice_value:.3f} m")
150
+ ax.set_aspect("equal", adjustable="box")
151
+ ax.grid(True, alpha=0.3)
152
+
153
+ if show_colorbar:
154
+ add_colorbar(ax, scatter, clabel)
155
+
156
+ return scatter
157
+
158
+
159
+ # =============================================================================
160
+ # Wavelength Distribution Functions
161
+ # =============================================================================
162
+
163
+
164
+ def plot_wavelength_histogram(
165
+ ax: Axes,
166
+ rays: "RayBatch",
167
+ bins: int = 50,
168
+ alpha: float = 0.7,
169
+ color: str = "steelblue",
170
+ edgecolor: str = "black",
171
+ label: str | None = None,
172
+ weight_by_intensity: bool = False,
173
+ ) -> None:
174
+ """
175
+ Plot histogram of ray wavelengths.
176
+
177
+ Parameters
178
+ ----------
179
+ ax : Axes
180
+ Matplotlib axes.
181
+ rays : RayBatch
182
+ Ray batch.
183
+ bins : int
184
+ Number of histogram bins.
185
+ alpha : float
186
+ Bar transparency.
187
+ color : str
188
+ Bar color.
189
+ edgecolor : str
190
+ Edge color.
191
+ label : str, optional
192
+ Legend label.
193
+ weight_by_intensity : bool
194
+ If True, weight histogram by ray intensities.
195
+ """
196
+ active_mask = rays.active
197
+ wavelengths = rays.wavelengths[active_mask] * 1e9 # nm
198
+
199
+ weights = rays.intensities[active_mask] if weight_by_intensity else None
200
+ ylabel = "Total Intensity" if weight_by_intensity else "Count"
201
+ title = (
202
+ "Intensity per Wavelength" if weight_by_intensity else "Wavelength Distribution"
203
+ )
204
+
205
+ ax.hist(
206
+ wavelengths,
207
+ bins=bins,
208
+ weights=weights,
209
+ alpha=alpha,
210
+ color=color,
211
+ edgecolor=edgecolor,
212
+ label=label,
213
+ )
214
+ setup_axis_grid(ax, "Wavelength (nm)", ylabel, title)
215
+
216
+
217
+ def plot_wavelength_intensity_histogram(
218
+ ax: Axes,
219
+ rays: "RayBatch",
220
+ bins: int = 50,
221
+ alpha: float = 0.7,
222
+ color: str = "orange",
223
+ edgecolor: str = "black",
224
+ label: str | None = None,
225
+ ) -> None:
226
+ """
227
+ Plot intensity-weighted histogram of ray wavelengths.
228
+
229
+ Deprecated: Use plot_wavelength_histogram(weight_by_intensity=True) instead.
230
+
231
+ Parameters
232
+ ----------
233
+ ax : Axes
234
+ Matplotlib axes.
235
+ rays : RayBatch
236
+ Ray batch.
237
+ bins : int
238
+ Number of histogram bins.
239
+ alpha : float
240
+ Bar transparency.
241
+ color : str
242
+ Bar color.
243
+ edgecolor : str
244
+ Edge color.
245
+ label : str, optional
246
+ Legend label.
247
+ """
248
+ return plot_wavelength_histogram(
249
+ ax,
250
+ rays,
251
+ bins=bins,
252
+ alpha=alpha,
253
+ color=color,
254
+ edgecolor=edgecolor,
255
+ label=label,
256
+ weight_by_intensity=True,
257
+ )
258
+
259
+
260
+ # =============================================================================
261
+ # Detection Count and Efficiency Functions
262
+ # =============================================================================
263
+
264
+
265
+ def plot_detection_counts(
266
+ ax: Axes,
267
+ detector_angles_deg: np.ndarray,
268
+ detection_counts: np.ndarray,
269
+ color: str = "blue",
270
+ marker: str = "o",
271
+ linewidth: float = 2,
272
+ markersize: float = 8,
273
+ label: str | None = None,
274
+ ) -> None:
275
+ """
276
+ Plot detection counts vs detector angle.
277
+
278
+ Parameters
279
+ ----------
280
+ ax : Axes
281
+ Matplotlib axes.
282
+ detector_angles_deg : ndarray
283
+ Detector angles in degrees.
284
+ detection_counts : ndarray
285
+ Number of rays detected.
286
+ color : str
287
+ Line color.
288
+ marker : str
289
+ Marker style.
290
+ linewidth : float
291
+ Line width.
292
+ markersize : float
293
+ Marker size.
294
+ label : str, optional
295
+ Legend label.
296
+ """
297
+ ax.plot(
298
+ detector_angles_deg,
299
+ detection_counts,
300
+ f"{color[0]}{marker}-",
301
+ linewidth=linewidth,
302
+ markersize=markersize,
303
+ label=label,
304
+ )
305
+ ax.axhline(0, color="k", linestyle="-", linewidth=0.5)
306
+ setup_axis_grid(ax, "Detector Angle (degrees)", "Detected Rays", "Detection Count")
307
+ ax.set_xlim(0, 90)
308
+
309
+
310
+ def plot_detection_efficiency(
311
+ ax: Axes,
312
+ detector_angles_deg: np.ndarray,
313
+ detected_intensities: np.ndarray,
314
+ total_source_intensity: float,
315
+ color: str = "magenta",
316
+ marker: str = "o",
317
+ linewidth: float = 2,
318
+ markersize: float = 8,
319
+ label: str | None = None,
320
+ ) -> None:
321
+ """
322
+ Plot detection efficiency vs detector angle.
323
+
324
+ Parameters
325
+ ----------
326
+ ax : Axes
327
+ Matplotlib axes.
328
+ detector_angles_deg : ndarray
329
+ Detector angles in degrees.
330
+ detected_intensities : ndarray
331
+ Total intensity detected.
332
+ total_source_intensity : float
333
+ Total source intensity.
334
+ color : str
335
+ Line color.
336
+ marker : str
337
+ Marker style.
338
+ linewidth : float
339
+ Line width.
340
+ markersize : float
341
+ Marker size.
342
+ label : str, optional
343
+ Legend label.
344
+ """
345
+ efficiency = detected_intensities / total_source_intensity * 100
346
+
347
+ ax.plot(
348
+ detector_angles_deg,
349
+ efficiency,
350
+ f"{color[0]}{marker}-",
351
+ linewidth=linewidth,
352
+ markersize=markersize,
353
+ label=label,
354
+ )
355
+ ax.axhline(0, color="k", linestyle="-", linewidth=0.5)
356
+ setup_axis_grid(
357
+ ax, "Detector Angle (degrees)", "Efficiency (%)", "Detection Efficiency"
358
+ )
359
+ ax.set_xlim(0, 90)
360
+ ax.set_ylim(0, max(efficiency) * 1.1 if max(efficiency) > 0 else 1)
361
+
362
+
363
+ # =============================================================================
364
+ # Arrival Time Functions
365
+ # =============================================================================
366
+
367
+
368
+ def plot_mean_arrival_time(
369
+ ax: Axes,
370
+ detector_angles_deg: np.ndarray,
371
+ mean_times: np.ndarray,
372
+ std_times: np.ndarray | None = None,
373
+ detection_counts: np.ndarray | None = None,
374
+ color: str = "cyan",
375
+ marker: str = "o",
376
+ linewidth: float = 2,
377
+ markersize: float = 8,
378
+ label: str | None = None,
379
+ ) -> None:
380
+ """
381
+ Plot mean arrival time vs detector angle.
382
+
383
+ Parameters
384
+ ----------
385
+ ax : Axes
386
+ Matplotlib axes.
387
+ detector_angles_deg : ndarray
388
+ Detector angles in degrees.
389
+ mean_times : ndarray
390
+ Mean arrival times in seconds.
391
+ std_times : ndarray, optional
392
+ Standard deviation of arrival times.
393
+ detection_counts : ndarray, optional
394
+ For masking invalid data.
395
+ color : str
396
+ Line color.
397
+ marker : str
398
+ Marker style.
399
+ linewidth : float
400
+ Line width.
401
+ markersize : float
402
+ Marker size.
403
+ label : str, optional
404
+ Legend label.
405
+ """
406
+ if detection_counts is not None:
407
+ valid_mask = detection_counts > 0
408
+ angles = detector_angles_deg[valid_mask]
409
+ times = mean_times[valid_mask] * 1e6 # to microseconds
410
+ yerr = std_times[valid_mask] * 1e6 if std_times is not None else None
411
+ else:
412
+ angles = detector_angles_deg
413
+ times = mean_times * 1e6
414
+ yerr = std_times * 1e6 if std_times is not None else None
415
+
416
+ ax.errorbar(
417
+ angles,
418
+ times,
419
+ yerr=yerr,
420
+ fmt=f"{color[0]}{marker}-",
421
+ linewidth=linewidth,
422
+ markersize=markersize,
423
+ capsize=5,
424
+ alpha=0.7,
425
+ label=label,
426
+ )
427
+ setup_axis_grid(
428
+ ax, "Detector Angle (degrees)", "Mean Arrival Time (μs)", "Arrival Time"
429
+ )
430
+ ax.set_xlim(0, 90)
431
+
432
+
433
+ def plot_timing_distribution(
434
+ ax: Axes,
435
+ all_time_distributions: list[tuple],
436
+ detector_angles_deg: np.ndarray,
437
+ log_scale: bool = True,
438
+ show_legend: bool = True,
439
+ max_curves: int = 10,
440
+ ) -> None:
441
+ """
442
+ Plot arrival time distributions for multiple detector positions.
443
+
444
+ Parameters
445
+ ----------
446
+ ax : Axes
447
+ Matplotlib axes.
448
+ all_time_distributions : list
449
+ List of (times, intensities, angles) tuples for each detector.
450
+ detector_angles_deg : ndarray
451
+ Detector angles in degrees.
452
+ log_scale : bool
453
+ Whether to use log scales.
454
+ show_legend : bool
455
+ Whether to show legend.
456
+ max_curves : int
457
+ Maximum number of curves to plot.
458
+ """
459
+ # Find first arrival time
460
+ all_times = []
461
+ for time_data in all_time_distributions:
462
+ if len(time_data) == 3:
463
+ times, _, _ = time_data
464
+ if len(times) > 0:
465
+ all_times.extend(times)
466
+
467
+ if len(all_times) == 0:
468
+ ax.text(
469
+ 0.5, 0.5, "No timing data", ha="center", va="center", transform=ax.transAxes
470
+ )
471
+ return
472
+
473
+ first_arrival = np.min(all_times)
474
+ log_bin_edges = np.logspace(-9, -3, 50) # seconds
475
+
476
+ # Collect positions with data
477
+ positions_with_data = []
478
+ for i, angle_deg in enumerate(detector_angles_deg):
479
+ time_data = all_time_distributions[i]
480
+ if len(time_data) == 3:
481
+ times, intensities, _ = time_data
482
+ if len(times) > 0:
483
+ times_rel = times - first_arrival
484
+ counts, _ = np.histogram(
485
+ times_rel, bins=log_bin_edges, weights=intensities
486
+ )
487
+ if counts.sum() > 0:
488
+ positions_with_data.append((angle_deg, counts))
489
+
490
+ if len(positions_with_data) == 0:
491
+ ax.text(
492
+ 0.5, 0.5, "No timing data", ha="center", va="center", transform=ax.transAxes
493
+ )
494
+ return
495
+
496
+ # Sample if too many curves
497
+ if len(positions_with_data) > max_curves:
498
+ indices = np.linspace(0, len(positions_with_data) - 1, max_curves, dtype=int)
499
+ positions_with_data = [positions_with_data[i] for i in indices]
500
+
501
+ colors = plt.cm.turbo(np.linspace(0, 1, len(positions_with_data)))
502
+ bin_centers = (log_bin_edges[:-1] + log_bin_edges[1:]) / 2
503
+
504
+ for idx, (angle_deg, counts) in enumerate(positions_with_data):
505
+ label = f"{angle_deg:.0f}°" if show_legend else ""
506
+ ax.plot(
507
+ bin_centers * 1e9,
508
+ counts,
509
+ color=colors[idx],
510
+ linewidth=1.5,
511
+ label=label,
512
+ alpha=0.7,
513
+ )
514
+
515
+ setup_axis_grid(
516
+ ax, "Relative Arrival Time (ns)", "Intensity", "Timing Distribution"
517
+ )
518
+ if log_scale:
519
+ ax.set_xscale("log")
520
+ ax.set_yscale("log")
521
+ if show_legend:
522
+ ax.legend(loc="upper right", fontsize=8, ncol=2)
523
+
524
+
525
+ # =============================================================================
526
+ # Angular Distribution Functions
527
+ # =============================================================================
528
+
529
+
530
+ def plot_arrival_angle_distribution(
531
+ ax: Axes,
532
+ detector_angles_deg: np.ndarray,
533
+ mean_angles: np.ndarray,
534
+ std_angles: np.ndarray | None = None,
535
+ detection_counts: np.ndarray | None = None,
536
+ color: str = "magenta",
537
+ marker: str = "o",
538
+ linewidth: float = 2,
539
+ markersize: float = 8,
540
+ ) -> None:
541
+ """
542
+ Plot mean arrival angle vs detector position.
543
+
544
+ Parameters
545
+ ----------
546
+ ax : Axes
547
+ Matplotlib axes.
548
+ detector_angles_deg : ndarray
549
+ Detector position angles.
550
+ mean_angles : ndarray
551
+ Mean arrival angles to normal.
552
+ std_angles : ndarray, optional
553
+ Standard deviation.
554
+ detection_counts : ndarray, optional
555
+ For masking invalid data.
556
+ color : str
557
+ Line color.
558
+ marker : str
559
+ Marker style.
560
+ linewidth : float
561
+ Line width.
562
+ markersize : float
563
+ Marker size.
564
+ """
565
+ if detection_counts is not None:
566
+ valid_mask = detection_counts > 0
567
+ angles = detector_angles_deg[valid_mask]
568
+ means = mean_angles[valid_mask]
569
+ yerr = std_angles[valid_mask] if std_angles is not None else None
570
+ else:
571
+ angles = detector_angles_deg
572
+ means = mean_angles
573
+ yerr = std_angles
574
+
575
+ ax.errorbar(
576
+ angles,
577
+ means,
578
+ yerr=yerr,
579
+ fmt=f"{color[0]}{marker}-",
580
+ linewidth=linewidth,
581
+ markersize=markersize,
582
+ capsize=5,
583
+ alpha=0.7,
584
+ )
585
+ setup_axis_grid(
586
+ ax,
587
+ "Detector Angle (degrees)",
588
+ "Mean Angle to Normal (degrees)",
589
+ "Arrival Angles",
590
+ )
591
+ ax.set_xlim(0, 90)
592
+
593
+
594
+ def plot_angular_histogram(
595
+ ax: Axes,
596
+ all_time_distributions: list[tuple],
597
+ detection_counts: np.ndarray,
598
+ bins: int = 50,
599
+ color: str = "purple",
600
+ alpha: float = 0.7,
601
+ ) -> None:
602
+ """
603
+ Plot histogram of all arrival angles.
604
+
605
+ Parameters
606
+ ----------
607
+ ax : Axes
608
+ Matplotlib axes.
609
+ all_time_distributions : list
610
+ List of (times, intensities, angles) tuples.
611
+ detection_counts : ndarray
612
+ Detection counts per position.
613
+ bins : int
614
+ Number of histogram bins.
615
+ color : str
616
+ Bar color.
617
+ alpha : float
618
+ Transparency.
619
+ """
620
+ all_angles = []
621
+ all_intensities = []
622
+
623
+ for i, time_data in enumerate(all_time_distributions):
624
+ if detection_counts[i] > 0 and len(time_data) == 3:
625
+ _, intensities, angles = time_data
626
+ all_angles.extend(angles)
627
+ all_intensities.extend(intensities)
628
+
629
+ if len(all_angles) == 0:
630
+ ax.text(
631
+ 0.5,
632
+ 0.5,
633
+ "No angular data",
634
+ ha="center",
635
+ va="center",
636
+ transform=ax.transAxes,
637
+ )
638
+ return
639
+
640
+ all_angles = np.array(all_angles)
641
+ all_intensities = np.array(all_intensities)
642
+
643
+ ax.hist(
644
+ all_angles,
645
+ bins=bins,
646
+ weights=all_intensities,
647
+ color=color,
648
+ alpha=alpha,
649
+ edgecolor="black",
650
+ )
651
+ setup_axis_grid(
652
+ ax, "Angle to Normal (degrees)", "Intensity", "Angular Distribution"
653
+ )
654
+
655
+
656
+ # =============================================================================
657
+ # Composite Figure Builders
658
+ # =============================================================================
659
+
660
+
661
+ def create_wavelength_figure(
662
+ rays: "RayBatch",
663
+ bins: int = 50,
664
+ figsize: tuple[float, float] = (10, 5),
665
+ title: str = "Wavelength Distribution",
666
+ save_path: str | None = None,
667
+ ) -> Figure:
668
+ """
669
+ Create figure with wavelength histograms.
670
+
671
+ Parameters
672
+ ----------
673
+ rays : RayBatch
674
+ Ray batch.
675
+ bins : int
676
+ Histogram bins.
677
+ figsize : tuple
678
+ Figure size.
679
+ title : str
680
+ Figure title.
681
+ save_path : str, optional
682
+ Save path.
683
+
684
+ Returns
685
+ -------
686
+ Figure
687
+ Matplotlib figure.
688
+ """
689
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize, constrained_layout=True)
690
+ fig.suptitle(title, fontsize=14, fontweight="bold")
691
+
692
+ plot_wavelength_histogram(ax1, rays, bins=bins)
693
+ plot_wavelength_intensity_histogram(ax2, rays, bins=bins)
694
+
695
+ if save_path:
696
+ save_figure(fig, save_path)
697
+
698
+ return fig
699
+
700
+
701
+ def create_beam_profile_figure(
702
+ rays: "RayBatch",
703
+ axis: str = "z",
704
+ num_slices: int = 5,
705
+ figsize: tuple[float, float] = (15, 4),
706
+ title: str | None = None,
707
+ save_path: str | None = None,
708
+ ) -> Figure:
709
+ """
710
+ Create figure showing beam profile at multiple slices.
711
+
712
+ Parameters
713
+ ----------
714
+ rays : RayBatch
715
+ Ray batch.
716
+ axis : str
717
+ Propagation axis.
718
+ num_slices : int
719
+ Number of slices.
720
+ figsize : tuple
721
+ Figure size.
722
+ title : str, optional
723
+ Figure title.
724
+ save_path : str, optional
725
+ Save path.
726
+
727
+ Returns
728
+ -------
729
+ Figure
730
+ Matplotlib figure.
731
+ """
732
+ fig, axes = plt.subplots(1, num_slices, figsize=figsize, constrained_layout=True)
733
+
734
+ if title is None:
735
+ title = f"Beam Profile Along {axis.upper()} Axis"
736
+ fig.suptitle(title, fontsize=14, fontweight="bold")
737
+
738
+ if num_slices == 1:
739
+ axes = [axes]
740
+
741
+ active_mask = rays.active
742
+ positions = rays.positions[active_mask]
743
+
744
+ axis_map = {"x": 0, "y": 1, "z": 2}
745
+ axis_idx = axis_map.get(axis.lower(), 2)
746
+
747
+ axis_pos = positions[:, axis_idx]
748
+ axis_min, axis_max = np.min(axis_pos), np.max(axis_pos)
749
+ slice_centers = np.linspace(axis_min, axis_max, num_slices)
750
+ slice_width = (
751
+ (axis_max - axis_min) / (num_slices - 1)
752
+ if num_slices > 1
753
+ else (axis_max - axis_min)
754
+ )
755
+
756
+ for i, (ax, center) in enumerate(zip(axes, slice_centers, strict=False)):
757
+ plot_beam_slice(
758
+ ax,
759
+ rays,
760
+ axis=axis,
761
+ slice_value=center,
762
+ slice_width=slice_width,
763
+ show_colorbar=(i == num_slices - 1),
764
+ )
765
+
766
+ if save_path:
767
+ save_figure(fig, save_path)
768
+
769
+ return fig
770
+
771
+
772
+ def create_detector_scan_figure(
773
+ detector_angles_deg: np.ndarray,
774
+ detection_counts: np.ndarray,
775
+ detected_intensities: np.ndarray,
776
+ total_source_intensity: float,
777
+ mean_arrival_times: np.ndarray | None = None,
778
+ std_arrival_times: np.ndarray | None = None,
779
+ mean_angles_to_normal: np.ndarray | None = None,
780
+ std_angles_to_normal: np.ndarray | None = None,
781
+ all_time_distributions: list | None = None,
782
+ figsize: tuple[float, float] = (16, 12),
783
+ title: str = "Detector Scan Results",
784
+ save_path: str | None = None,
785
+ ) -> Figure:
786
+ """
787
+ Create comprehensive detector scan figure.
788
+
789
+ Parameters
790
+ ----------
791
+ detector_angles_deg : ndarray
792
+ Detector angles in degrees.
793
+ detection_counts : ndarray
794
+ Number of rays detected.
795
+ detected_intensities : ndarray
796
+ Total intensity detected.
797
+ total_source_intensity : float
798
+ Source intensity.
799
+ mean_arrival_times : ndarray, optional
800
+ Mean arrival times.
801
+ std_arrival_times : ndarray, optional
802
+ Std of arrival times.
803
+ mean_angles_to_normal : ndarray, optional
804
+ Mean arrival angles.
805
+ std_angles_to_normal : ndarray, optional
806
+ Std of arrival angles.
807
+ all_time_distributions : list, optional
808
+ Timing data.
809
+ figsize : tuple
810
+ Figure size.
811
+ title : str
812
+ Figure title.
813
+ save_path : str, optional
814
+ Save path.
815
+
816
+ Returns
817
+ -------
818
+ Figure
819
+ Matplotlib figure.
820
+ """
821
+ fig, axes = plt.subplots(3, 2, figsize=figsize, constrained_layout=True)
822
+ fig.suptitle(title, fontsize=14, fontweight="bold")
823
+
824
+ # Row 1: Detection counts and efficiency
825
+ plot_detection_counts(axes[0, 0], detector_angles_deg, detection_counts)
826
+ plot_detection_efficiency(
827
+ axes[0, 1], detector_angles_deg, detected_intensities, total_source_intensity
828
+ )
829
+
830
+ # Row 2: Arrival times and angles
831
+ if mean_arrival_times is not None:
832
+ plot_mean_arrival_time(
833
+ axes[1, 0],
834
+ detector_angles_deg,
835
+ mean_arrival_times,
836
+ std_arrival_times,
837
+ detection_counts,
838
+ )
839
+ else:
840
+ axes[1, 0].text(
841
+ 0.5,
842
+ 0.5,
843
+ "No timing data",
844
+ ha="center",
845
+ va="center",
846
+ transform=axes[1, 0].transAxes,
847
+ )
848
+
849
+ if mean_angles_to_normal is not None:
850
+ plot_arrival_angle_distribution(
851
+ axes[1, 1],
852
+ detector_angles_deg,
853
+ mean_angles_to_normal,
854
+ std_angles_to_normal,
855
+ detection_counts,
856
+ )
857
+ else:
858
+ axes[1, 1].text(
859
+ 0.5,
860
+ 0.5,
861
+ "No angular data",
862
+ ha="center",
863
+ va="center",
864
+ transform=axes[1, 1].transAxes,
865
+ )
866
+
867
+ # Row 3: Distributions
868
+ if all_time_distributions is not None:
869
+ plot_timing_distribution(
870
+ axes[2, 0], all_time_distributions, detector_angles_deg
871
+ )
872
+ plot_angular_histogram(axes[2, 1], all_time_distributions, detection_counts)
873
+ else:
874
+ axes[2, 0].text(
875
+ 0.5,
876
+ 0.5,
877
+ "No distribution data",
878
+ ha="center",
879
+ va="center",
880
+ transform=axes[2, 0].transAxes,
881
+ )
882
+ axes[2, 1].text(
883
+ 0.5,
884
+ 0.5,
885
+ "No distribution data",
886
+ ha="center",
887
+ va="center",
888
+ transform=axes[2, 1].transAxes,
889
+ )
890
+
891
+ if save_path:
892
+ save_figure(fig, save_path)
893
+
894
+ return fig
895
+
896
+
897
+ def plot_detector_scan_results(
898
+ detector_angles_deg: np.ndarray,
899
+ detection_counts: np.ndarray,
900
+ detected_intensities: np.ndarray,
901
+ reflected_rays: "RayBatch",
902
+ surface: "Surface",
903
+ total_source_intensity: float,
904
+ mean_arrival_times: np.ndarray | None = None,
905
+ std_arrival_times: np.ndarray | None = None,
906
+ mean_angles_to_normal: np.ndarray | None = None,
907
+ std_angles_to_normal: np.ndarray | None = None,
908
+ all_time_distributions: list | None = None,
909
+ detector_distance: float = 1000.0,
910
+ detector_radius: float = 5.0,
911
+ water_normal: np.ndarray = None,
912
+ figsize: tuple[float, float] = (24, 14),
913
+ save_path: str | None = None,
914
+ ) -> Figure:
915
+ """
916
+ Create comprehensive detector scan visualization with multiple subplots.
917
+
918
+ This is the full production visualization with all panels including
919
+ the wave surface and ray visualizations.
920
+
921
+ Parameters
922
+ ----------
923
+ detector_angles_deg : ndarray
924
+ Detector angles in degrees (0-90).
925
+ detection_counts : ndarray
926
+ Number of rays detected at each position.
927
+ detected_intensities : ndarray
928
+ Total intensity detected at each position.
929
+ reflected_rays : RayBatch
930
+ Batch of reflected rays.
931
+ surface : Surface
932
+ The surface object (must have _surface_z method).
933
+ total_source_intensity : float
934
+ Total intensity of source rays.
935
+ mean_arrival_times : ndarray, optional
936
+ Mean arrival time at each detector position.
937
+ std_arrival_times : ndarray, optional
938
+ Standard deviation of arrival times.
939
+ mean_angles_to_normal : ndarray, optional
940
+ Mean angle to normal at each detector.
941
+ std_angles_to_normal : ndarray, optional
942
+ Standard deviation of angles.
943
+ all_time_distributions : list, optional
944
+ List of (times, intensities, angles) tuples for each detector.
945
+ detector_distance : float
946
+ Distance to detector in meters.
947
+ detector_radius : float
948
+ Detector radius in meters.
949
+ water_normal : ndarray
950
+ Normal vector for water surface.
951
+ figsize : tuple
952
+ Figure size (width, height).
953
+ save_path : str, optional
954
+ Path to save figure.
955
+
956
+ Returns
957
+ -------
958
+ Figure
959
+ Matplotlib figure with comprehensive visualization.
960
+ """
961
+ import matplotlib.gridspec as gridspec
962
+
963
+ if water_normal is None:
964
+ water_normal = np.array([0, 0, 1])
965
+
966
+ # Create figure with 4x4 grid layout
967
+ fig = plt.figure(figsize=figsize, constrained_layout=True)
968
+ gs = gridspec.GridSpec(4, 4, figure=fig, hspace=0.3, wspace=0.4)
969
+
970
+ # Compute detection efficiency
971
+ detection_efficiency = detected_intensities / total_source_intensity * 100
972
+
973
+ # 1. Ray count per detector position
974
+ ax1 = fig.add_subplot(gs[1, :2])
975
+ ax1.plot(detector_angles_deg, detection_counts, "bo-", linewidth=2, markersize=8)
976
+ ax1.axhline(0, color="k", linestyle="-", linewidth=0.5)
977
+ ax1.set_xlabel(
978
+ "Detector Position Angle from Surface (degrees)", fontsize=11, fontweight="bold"
979
+ )
980
+ ax1.set_ylabel("Number of Detected Rays", fontsize=11, fontweight="bold")
981
+ ax1.set_title("Ray Detection Count vs Detector Position", fontweight="bold")
982
+ ax1.grid(True, alpha=0.3)
983
+ ax1.set_xlim(0, 90)
984
+
985
+ # 2. Mean arrival angle vs detector position
986
+ if mean_angles_to_normal is not None:
987
+ ax2 = fig.add_subplot(gs[1, 2:])
988
+ valid_mask = detection_counts > 0
989
+ ax2.errorbar(
990
+ detector_angles_deg[valid_mask],
991
+ mean_angles_to_normal[valid_mask],
992
+ yerr=(
993
+ std_angles_to_normal[valid_mask]
994
+ if std_angles_to_normal is not None
995
+ else None
996
+ ),
997
+ fmt="mo-",
998
+ linewidth=2,
999
+ markersize=8,
1000
+ capsize=5,
1001
+ alpha=0.7,
1002
+ )
1003
+ ax2.set_xlabel(
1004
+ "Detector Position Angle from Surface (degrees)",
1005
+ fontsize=11,
1006
+ fontweight="bold",
1007
+ )
1008
+ ax2.set_ylabel("Mean Angle to Normal (degrees)", fontsize=11, fontweight="bold")
1009
+ ax2.set_title("Mean Arrival Angle at Detector", fontweight="bold")
1010
+ ax2.grid(True, alpha=0.3)
1011
+ ax2.set_xlim(0, 90)
1012
+
1013
+ # 3a. Mean arrival time vs detector position
1014
+ if mean_arrival_times is not None:
1015
+ ax3a = fig.add_subplot(gs[2, :2])
1016
+ valid_mask = detection_counts > 0
1017
+ ax3a.errorbar(
1018
+ detector_angles_deg[valid_mask],
1019
+ mean_arrival_times[valid_mask] * 1e6, # Convert to microseconds
1020
+ yerr=(
1021
+ std_arrival_times[valid_mask] * 1e6
1022
+ if std_arrival_times is not None
1023
+ else None
1024
+ ),
1025
+ fmt="co-",
1026
+ linewidth=2,
1027
+ markersize=8,
1028
+ capsize=5,
1029
+ alpha=0.7,
1030
+ )
1031
+ ax3a.set_xlabel(
1032
+ "Detector Position Angle from Surface (degrees)",
1033
+ fontsize=11,
1034
+ fontweight="bold",
1035
+ )
1036
+ ax3a.set_ylabel("Mean Arrival Time (μs)", fontsize=11, fontweight="bold")
1037
+ ax3a.set_title("Mean Arrival Time at Detector", fontweight="bold")
1038
+ ax3a.grid(True, alpha=0.3)
1039
+ ax3a.set_xlim(0, 90)
1040
+
1041
+ # 3b. Detection efficiency vs detector position
1042
+ ax3b = fig.add_subplot(gs[2, 2:])
1043
+ ax3b.plot(
1044
+ detector_angles_deg, detection_efficiency, "mo-", linewidth=2, markersize=8
1045
+ )
1046
+ ax3b.axhline(0, color="k", linestyle="-", linewidth=0.5)
1047
+ ax3b.set_xlabel(
1048
+ "Detector Position Angle from Surface (degrees)", fontsize=11, fontweight="bold"
1049
+ )
1050
+ ax3b.set_ylabel("Detection Efficiency (%)", fontsize=11, fontweight="bold")
1051
+ ax3b.set_title("Detection Efficiency (Detected/Source)", fontweight="bold")
1052
+ ax3b.grid(True, alpha=0.3)
1053
+ ax3b.set_xlim(0, 90)
1054
+ ax3b.set_ylim(
1055
+ 0, max(detection_efficiency) * 1.1 if max(detection_efficiency) > 0 else 1
1056
+ )
1057
+
1058
+ # 4. Time distribution
1059
+ if all_time_distributions is not None:
1060
+ ax4 = fig.add_subplot(gs[3, 0:2])
1061
+
1062
+ # Find first arrival time across all detectors
1063
+ all_times_global = []
1064
+ for i, angle_deg in enumerate(detector_angles_deg):
1065
+ time_data = all_time_distributions[i]
1066
+ if len(time_data) == 3:
1067
+ times_raw, _, _ = time_data
1068
+ if len(times_raw) > 0:
1069
+ all_times_global.extend(times_raw)
1070
+
1071
+ if len(all_times_global) > 0:
1072
+ first_arrival = np.min(all_times_global)
1073
+ log_bin_edges = np.logspace(-9, -3, 50) # in seconds
1074
+
1075
+ # Identify positions with data
1076
+ positions_with_data = []
1077
+ for i, angle_deg in enumerate(detector_angles_deg):
1078
+ time_data = all_time_distributions[i]
1079
+ if len(time_data) == 3:
1080
+ times_raw, intensities_raw, _ = time_data
1081
+ if len(times_raw) > 0:
1082
+ times_relative = times_raw - first_arrival
1083
+ counts, _ = np.histogram(
1084
+ times_relative, bins=log_bin_edges, weights=intensities_raw
1085
+ )
1086
+ if counts.sum() > 0:
1087
+ positions_with_data.append(
1088
+ (i, angle_deg, times_relative, counts, intensities_raw)
1089
+ )
1090
+
1091
+ # Plot histograms
1092
+ if len(positions_with_data) > 0:
1093
+ colors = plt.cm.turbo(np.linspace(0, 1, len(positions_with_data)))
1094
+
1095
+ for color_idx, (
1096
+ i,
1097
+ angle_deg,
1098
+ times_relative,
1099
+ counts,
1100
+ intensities_raw,
1101
+ ) in enumerate(positions_with_data):
1102
+ bin_centers = (log_bin_edges[:-1] + log_bin_edges[1:]) / 2
1103
+ ax4.plot(
1104
+ bin_centers * 1e9, # Convert to nanoseconds
1105
+ counts,
1106
+ color=colors[color_idx],
1107
+ linewidth=1.5,
1108
+ label=f"{angle_deg:.0f}°" if color_idx % 10 == 0 else "",
1109
+ alpha=0.7,
1110
+ )
1111
+
1112
+ ax4.set_xlabel("Relative Arrival Time (ns)", fontsize=11, fontweight="bold")
1113
+ ax4.set_ylabel(
1114
+ "Intensity (weighted counts)", fontsize=11, fontweight="bold"
1115
+ )
1116
+ ax4.set_title("Timing Distribution (intensity-weighted)", fontweight="bold")
1117
+ ax4.set_xscale("log")
1118
+ ax4.set_yscale("log")
1119
+ ax4.grid(True, alpha=0.3, which="both")
1120
+ if len(positions_with_data) > 0:
1121
+ ax4.legend(loc="upper right", fontsize=8, ncol=2)
1122
+
1123
+ # 5. Angular distribution
1124
+ if all_time_distributions is not None:
1125
+ ax5 = fig.add_subplot(gs[3, 2:])
1126
+
1127
+ all_angles = []
1128
+ intensities_for_angles = []
1129
+ for i, angle_deg in enumerate(detector_angles_deg):
1130
+ if detection_counts[i] > 0:
1131
+ time_data = all_time_distributions[i]
1132
+ if len(time_data) == 3:
1133
+ _, intensities_raw, angles_raw = time_data
1134
+ all_angles.extend(angles_raw)
1135
+ intensities_for_angles.extend(intensities_raw)
1136
+
1137
+ if len(all_angles) > 0:
1138
+ all_angles = np.array(all_angles)
1139
+ intensities_for_angles = np.array(intensities_for_angles)
1140
+
1141
+ ax5.hist(
1142
+ all_angles,
1143
+ bins=50,
1144
+ weights=intensities_for_angles,
1145
+ color="purple",
1146
+ alpha=0.7,
1147
+ edgecolor="black",
1148
+ )
1149
+ ax5.set_xlabel(
1150
+ "Angle to Water Normal (degrees)", fontsize=11, fontweight="bold"
1151
+ )
1152
+ ax5.set_ylabel(
1153
+ "Intensity (weighted counts)", fontsize=11, fontweight="bold"
1154
+ )
1155
+ ax5.set_title(
1156
+ "Angular Distribution of Detected Rays (intensity-weighted)",
1157
+ fontweight="bold",
1158
+ )
1159
+ ax5.grid(True, alpha=0.3)
1160
+
1161
+ if save_path:
1162
+ fig.savefig(save_path, dpi=150, bbox_inches="tight")
1163
+
1164
+ return fig
1165
+
1166
+
1167
+ # =============================================================================
1168
+ # Legacy Convenience Functions (Backward Compatibility)
1169
+ # =============================================================================
1170
+
1171
+
1172
+ def plot_statistics_evolution(
1173
+ stats_history: list["RayStatistics"],
1174
+ figsize: tuple[float, float] = (15, 10),
1175
+ save_path: str | None = None,
1176
+ ) -> Figure:
1177
+ """
1178
+ Create figure showing evolution of ray statistics over propagation.
1179
+
1180
+ This is a convenience function for visualizing how beam properties
1181
+ change during propagation.
1182
+
1183
+ Parameters
1184
+ ----------
1185
+ stats_history : List[RayStatistics]
1186
+ List of RayStatistics objects at different propagation steps.
1187
+ figsize : tuple
1188
+ Figure size.
1189
+ save_path : str, optional
1190
+ Path to save figure.
1191
+
1192
+ Returns
1193
+ -------
1194
+ Figure
1195
+ Matplotlib figure with statistics evolution.
1196
+ """
1197
+ if len(stats_history) == 0:
1198
+ fig, ax = plt.subplots(1, 1, figsize=figsize)
1199
+ ax.text(
1200
+ 0.5,
1201
+ 0.5,
1202
+ "No statistics available",
1203
+ ha="center",
1204
+ va="center",
1205
+ transform=ax.transAxes,
1206
+ )
1207
+ return fig
1208
+
1209
+ # Extract statistics
1210
+ steps = np.arange(len(stats_history))
1211
+ active_rays = [s.active_rays for s in stats_history]
1212
+ total_power = [s.total_power for s in stats_history]
1213
+ mean_path = [s.mean_optical_path for s in stats_history]
1214
+
1215
+ # Optional: mean positions if available
1216
+ mean_x = [
1217
+ s.mean_position[0] if hasattr(s, "mean_position") else 0 for s in stats_history
1218
+ ]
1219
+ mean_y = [
1220
+ s.mean_position[1] if hasattr(s, "mean_position") else 0 for s in stats_history
1221
+ ]
1222
+ mean_z = [
1223
+ s.mean_position[2] if hasattr(s, "mean_position") else 0 for s in stats_history
1224
+ ]
1225
+
1226
+ fig, axes = plt.subplots(2, 2, figsize=figsize, constrained_layout=True)
1227
+ fig.suptitle("Ray Statistics Evolution", fontsize=14, fontweight="bold")
1228
+
1229
+ # Active rays
1230
+ ax = axes[0, 0]
1231
+ ax.plot(steps, active_rays, "b-o", markersize=4)
1232
+ ax.set_xlabel("Step")
1233
+ ax.set_ylabel("Active Rays")
1234
+ ax.set_title("Active Ray Count")
1235
+ ax.grid(True, alpha=0.3)
1236
+
1237
+ # Total power
1238
+ ax = axes[0, 1]
1239
+ ax.plot(steps, total_power, "g-o", markersize=4)
1240
+ ax.set_xlabel("Step")
1241
+ ax.set_ylabel("Power (W)")
1242
+ ax.set_title("Total Power")
1243
+ ax.grid(True, alpha=0.3)
1244
+
1245
+ # Mean optical path
1246
+ ax = axes[1, 0]
1247
+ ax.plot(steps, mean_path, "r-o", markersize=4)
1248
+ ax.set_xlabel("Step")
1249
+ ax.set_ylabel("Path Length (m)")
1250
+ ax.set_title("Mean Optical Path")
1251
+ ax.grid(True, alpha=0.3)
1252
+
1253
+ # Mean position
1254
+ ax = axes[1, 1]
1255
+ ax.plot(steps, mean_z, "purple", label="Z", marker="o", markersize=4)
1256
+ ax.plot(steps, mean_x, "orange", label="X", marker="s", markersize=4, alpha=0.7)
1257
+ ax.plot(steps, mean_y, "cyan", label="Y", marker="^", markersize=4, alpha=0.7)
1258
+ ax.set_xlabel("Step")
1259
+ ax.set_ylabel("Position (m)")
1260
+ ax.set_title("Mean Position")
1261
+ ax.legend()
1262
+ ax.grid(True, alpha=0.3)
1263
+
1264
+ if save_path:
1265
+ from .common import save_figure
1266
+
1267
+ save_figure(fig, save_path)
1268
+
1269
+ return fig
1270
+
1271
+
1272
+ def plot_beam_profile(
1273
+ rays: "RayBatch",
1274
+ axis: str = "z",
1275
+ num_slices: int = 5,
1276
+ figsize: tuple[float, float] = (15, 4),
1277
+ save_path: str | None = None,
1278
+ ) -> Figure:
1279
+ """
1280
+ Create figure showing beam profile at multiple slices.
1281
+
1282
+ This is an alias for create_beam_profile_figure for backward compatibility.
1283
+
1284
+ Parameters
1285
+ ----------
1286
+ rays : RayBatch
1287
+ Ray batch.
1288
+ axis : str
1289
+ Propagation axis: 'x', 'y', 'z'.
1290
+ num_slices : int
1291
+ Number of slices to show.
1292
+ figsize : tuple
1293
+ Figure size.
1294
+ save_path : str, optional
1295
+ Path to save figure.
1296
+
1297
+ Returns
1298
+ -------
1299
+ Figure
1300
+ Matplotlib figure.
1301
+ """
1302
+ return create_beam_profile_figure(
1303
+ rays=rays,
1304
+ axis=axis,
1305
+ num_slices=num_slices,
1306
+ figsize=figsize,
1307
+ save_path=save_path,
1308
+ )
1309
+
1310
+
1311
+ def plot_wavelength_distribution(
1312
+ rays: "RayBatch",
1313
+ bins: int = 50,
1314
+ figsize: tuple[float, float] = (10, 6),
1315
+ save_path: str | None = None,
1316
+ ) -> Figure:
1317
+ """
1318
+ Create figure showing wavelength distribution of rays.
1319
+
1320
+ This is a convenience function for quick visualization. For custom layouts,
1321
+ use plot_wavelength_histogram() on a single axis.
1322
+
1323
+ Parameters
1324
+ ----------
1325
+ rays : RayBatch
1326
+ Ray batch.
1327
+ bins : int
1328
+ Number of histogram bins.
1329
+ figsize : tuple
1330
+ Figure size.
1331
+ save_path : str, optional
1332
+ Path to save figure.
1333
+
1334
+ Returns
1335
+ -------
1336
+ Figure
1337
+ Matplotlib figure with wavelength histogram.
1338
+ """
1339
+ fig, ax = plt.subplots(1, 1, figsize=figsize, constrained_layout=True)
1340
+
1341
+ plot_wavelength_histogram(ax, rays, bins=bins)
1342
+
1343
+ fig.suptitle("Wavelength Distribution", fontsize=14, fontweight="bold")
1344
+
1345
+ if save_path:
1346
+ from .common import save_figure
1347
+
1348
+ save_figure(fig, save_path)
1349
+
1350
+ return fig