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,222 @@
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
+ Immutable Geometry Container
36
+
37
+ Holds the result of a GeometryBuilder.build() call.
38
+ Provides convenient accessors for surfaces, detectors, and materials.
39
+ """
40
+
41
+ from dataclasses import dataclass
42
+
43
+ from ..surfaces import Surface
44
+ from ..materials import MaterialField
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Geometry:
49
+ """
50
+ Immutable container for simulation geometry.
51
+
52
+ Created by GeometryBuilder.build(). Provides access to surfaces,
53
+ detectors, and named media by name or index.
54
+
55
+ Parameters
56
+ ----------
57
+ surfaces : tuple of Surface
58
+ All optical/absorber surfaces in the geometry.
59
+ detectors : tuple of Surface
60
+ All detector surfaces in the geometry.
61
+ background_material : MaterialField
62
+ The background/ambient material (from set_background() medium).
63
+ media : dict
64
+ Mapping from medium name to MaterialField.
65
+ surface_names : dict
66
+ Mapping from surface name to index in surfaces tuple.
67
+ detector_names : dict
68
+ Mapping from detector name to index in detectors tuple.
69
+
70
+ Examples
71
+ --------
72
+ >>> geometry = builder.build()
73
+ >>> ocean = geometry.get_surface("ocean")
74
+ >>> detector = geometry.get_detector("detector_35km")
75
+ >>> atmosphere_material = geometry.get_medium("atmosphere")
76
+ """
77
+
78
+ surfaces: tuple[Surface, ...]
79
+ detectors: tuple[Surface, ...]
80
+ background_material: MaterialField
81
+ media: dict[str, MaterialField]
82
+ surface_names: dict[str, int]
83
+ detector_names: dict[str, int]
84
+
85
+ def get_medium(self, name: str) -> MaterialField:
86
+ """
87
+ Get the material for a named medium.
88
+
89
+ Parameters
90
+ ----------
91
+ name : str
92
+ The name of the medium.
93
+
94
+ Returns
95
+ -------
96
+ MaterialField
97
+ The material for the medium.
98
+
99
+ Raises
100
+ ------
101
+ KeyError
102
+ If no medium with the given name exists.
103
+ """
104
+ if name not in self.media:
105
+ available = ", ".join(sorted(self.media.keys()))
106
+ raise KeyError(f"No medium named '{name}'. Available: {available}")
107
+ return self.media[name]
108
+
109
+ def get_surface(self, name: str) -> Surface:
110
+ """
111
+ Get a surface by name.
112
+
113
+ Parameters
114
+ ----------
115
+ name : str
116
+ The name of the surface.
117
+
118
+ Returns
119
+ -------
120
+ Surface
121
+ The surface with the given name.
122
+
123
+ Raises
124
+ ------
125
+ KeyError
126
+ If no surface with the given name exists.
127
+ """
128
+ if name not in self.surface_names:
129
+ available = ", ".join(sorted(self.surface_names.keys()))
130
+ raise KeyError(f"No surface named '{name}'. Available: {available}")
131
+ return self.surfaces[self.surface_names[name]]
132
+
133
+ def get_surface_index(self, name: str) -> int:
134
+ """
135
+ Get the index of a surface by name.
136
+
137
+ Parameters
138
+ ----------
139
+ name : str
140
+ The name of the surface.
141
+
142
+ Returns
143
+ -------
144
+ int
145
+ The index of the surface in the surfaces tuple.
146
+
147
+ Raises
148
+ ------
149
+ KeyError
150
+ If no surface with the given name exists.
151
+ """
152
+ if name not in self.surface_names:
153
+ available = ", ".join(sorted(self.surface_names.keys()))
154
+ raise KeyError(f"No surface named '{name}'. Available: {available}")
155
+ return self.surface_names[name]
156
+
157
+ def get_detector(self, name: str) -> Surface:
158
+ """
159
+ Get a detector by name.
160
+
161
+ Parameters
162
+ ----------
163
+ name : str
164
+ The name of the detector.
165
+
166
+ Returns
167
+ -------
168
+ Surface
169
+ The detector with the given name.
170
+
171
+ Raises
172
+ ------
173
+ KeyError
174
+ If no detector with the given name exists.
175
+ """
176
+ if name not in self.detector_names:
177
+ available = ", ".join(sorted(self.detector_names.keys()))
178
+ raise KeyError(f"No detector named '{name}'. Available: {available}")
179
+ return self.detectors[self.detector_names[name]]
180
+
181
+ def get_detector_index(self, name: str) -> int:
182
+ """
183
+ Get the index of a detector by name.
184
+
185
+ Parameters
186
+ ----------
187
+ name : str
188
+ The name of the detector.
189
+
190
+ Returns
191
+ -------
192
+ int
193
+ The index of the detector in the detectors tuple.
194
+
195
+ Raises
196
+ ------
197
+ KeyError
198
+ If no detector with the given name exists.
199
+ """
200
+ if name not in self.detector_names:
201
+ available = ", ".join(sorted(self.detector_names.keys()))
202
+ raise KeyError(f"No detector named '{name}'. Available: {available}")
203
+ return self.detector_names[name]
204
+
205
+ def to_surface_list(self) -> list[Surface]:
206
+ """
207
+ Convert all surfaces and detectors to a list for SurfacePropagator.
208
+
209
+ Returns
210
+ -------
211
+ list of Surface
212
+ All surfaces and detectors as a mutable list.
213
+ """
214
+ return list(self.surfaces) + list(self.detectors)
215
+
216
+ def __len__(self) -> int:
217
+ """Return the total number of surfaces and detectors."""
218
+ return len(self.surfaces) + len(self.detectors)
219
+
220
+ def __iter__(self):
221
+ """Iterate over all surfaces and detectors."""
222
+ return iter(self.surfaces + self.detectors)
@@ -0,0 +1,375 @@
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
+ Surface Relationship Analysis
36
+
37
+ Tools for analyzing geometric relationships between surfaces to detect
38
+ potential material assignment conflicts.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass
44
+ from enum import Enum, auto
45
+ from typing import TYPE_CHECKING
46
+
47
+ import numpy as np
48
+
49
+ if TYPE_CHECKING:
50
+ from numpy.typing import NDArray
51
+
52
+ from ..surfaces import Surface
53
+
54
+
55
+ class SurfaceRelationship(Enum):
56
+ """Classification of the geometric relationship between two surfaces."""
57
+
58
+ DISJOINT = auto() # Surfaces don't overlap in the domain of interest
59
+ PARALLEL = auto() # Surfaces are parallel (planes) or concentric (spheres)
60
+ NESTED = auto() # One surface completely contains the other
61
+ INTERSECTING = auto() # Surfaces intersect, creating multiple regions
62
+
63
+
64
+ @dataclass
65
+ class SurfaceAnalysisResult:
66
+ """
67
+ Result of analyzing the relationship between two surfaces.
68
+
69
+ Attributes
70
+ ----------
71
+ relationship : SurfaceRelationship
72
+ The geometric relationship between the surfaces.
73
+ details : str
74
+ Human-readable explanation of the analysis.
75
+ materials_consistent : bool
76
+ Whether material assignments are consistent for this relationship.
77
+ """
78
+
79
+ relationship: SurfaceRelationship
80
+ details: str
81
+ materials_consistent: bool
82
+
83
+
84
+ # Tolerance for parallel detection (cosine of angle between normals)
85
+ PARALLEL_TOLERANCE = 1e-6
86
+
87
+
88
+ def are_planes_parallel(
89
+ normal1: NDArray[np.float64],
90
+ normal2: NDArray[np.float64],
91
+ tolerance: float = PARALLEL_TOLERANCE,
92
+ ) -> bool:
93
+ """
94
+ Check if two planes are parallel based on their normals.
95
+
96
+ Parameters
97
+ ----------
98
+ normal1 : ndarray, shape (3,)
99
+ Normal vector of the first plane.
100
+ normal2 : ndarray, shape (3,)
101
+ Normal vector of the second plane.
102
+ tolerance : float, optional
103
+ Tolerance for parallel detection. Default is 1e-6.
104
+ Planes are considered parallel if |dot(n1, n2)| > 1 - tolerance.
105
+
106
+ Returns
107
+ -------
108
+ bool
109
+ True if the planes are parallel (or anti-parallel).
110
+ """
111
+ # Normalize vectors
112
+ n1 = np.asarray(normal1, dtype=np.float64)
113
+ n2 = np.asarray(normal2, dtype=np.float64)
114
+
115
+ n1 = n1 / np.linalg.norm(n1)
116
+ n2 = n2 / np.linalg.norm(n2)
117
+
118
+ # Check if parallel (dot product magnitude close to 1)
119
+ dot = np.abs(np.dot(n1, n2))
120
+ return dot > (1.0 - tolerance)
121
+
122
+
123
+ def are_spheres_concentric(
124
+ center1: NDArray[np.float64],
125
+ center2: NDArray[np.float64],
126
+ tolerance: float = 1e-6,
127
+ ) -> bool:
128
+ """
129
+ Check if two spheres are concentric (same center).
130
+
131
+ Parameters
132
+ ----------
133
+ center1 : ndarray, shape (3,)
134
+ Center of the first sphere.
135
+ center2 : ndarray, shape (3,)
136
+ Center of the second sphere.
137
+ tolerance : float, optional
138
+ Distance tolerance for considering centers equal.
139
+
140
+ Returns
141
+ -------
142
+ bool
143
+ True if the spheres share the same center.
144
+ """
145
+ c1 = np.asarray(center1, dtype=np.float64)
146
+ c2 = np.asarray(center2, dtype=np.float64)
147
+ return np.linalg.norm(c1 - c2) < tolerance
148
+
149
+
150
+ def _get_plane_normal(surface: Surface) -> NDArray[np.float64] | None:
151
+ """Extract normal from a plane surface, or None if not a plane."""
152
+ # Check for PlaneSurface or BoundedPlaneSurface by looking for normal attribute
153
+ if hasattr(surface, "normal"):
154
+ return np.asarray(surface.normal, dtype=np.float64)
155
+ return None
156
+
157
+
158
+ def _get_sphere_center(surface: Surface) -> NDArray[np.float64] | None:
159
+ """Extract center from a sphere surface, or None if not a sphere."""
160
+ if hasattr(surface, "center"):
161
+ return np.asarray(surface.center, dtype=np.float64)
162
+ return None
163
+
164
+
165
+ def analyze_surface_pair(
166
+ surface1: Surface,
167
+ surface2: Surface,
168
+ ) -> SurfaceAnalysisResult:
169
+ """
170
+ Analyze the geometric relationship between two surfaces.
171
+
172
+ Determines if surfaces are parallel, concentric, nested, or intersecting,
173
+ and checks whether material assignments are consistent.
174
+
175
+ Parameters
176
+ ----------
177
+ surface1 : Surface
178
+ First surface to analyze.
179
+ surface2 : Surface
180
+ Second surface to analyze.
181
+
182
+ Returns
183
+ -------
184
+ SurfaceAnalysisResult
185
+ Analysis result including relationship type and material consistency.
186
+
187
+ Notes
188
+ -----
189
+ For intersecting surfaces with different materials, the front/back model
190
+ cannot consistently assign materials to all regions. This function detects
191
+ such conflicts.
192
+ """
193
+ from ..surfaces import SurfaceRole
194
+
195
+ # Skip non-optical surfaces - they don't define materials
196
+ if surface1.role != SurfaceRole.OPTICAL or surface2.role != SurfaceRole.OPTICAL:
197
+ return SurfaceAnalysisResult(
198
+ relationship=SurfaceRelationship.DISJOINT,
199
+ details="Non-optical surfaces do not define material regions.",
200
+ materials_consistent=True,
201
+ )
202
+
203
+ # Check for plane-plane relationship
204
+ normal1 = _get_plane_normal(surface1)
205
+ normal2 = _get_plane_normal(surface2)
206
+
207
+ if normal1 is not None and normal2 is not None:
208
+ return _analyze_plane_pair(surface1, surface2, normal1, normal2)
209
+
210
+ # Check for sphere-sphere relationship
211
+ center1 = _get_sphere_center(surface1)
212
+ center2 = _get_sphere_center(surface2)
213
+
214
+ if center1 is not None and center2 is not None:
215
+ return _analyze_sphere_pair(surface1, surface2, center1, center2)
216
+
217
+ # Check for plane-sphere relationship
218
+ if normal1 is not None and center2 is not None:
219
+ return _analyze_plane_sphere(surface1, surface2)
220
+
221
+ if center1 is not None and normal2 is not None:
222
+ return _analyze_plane_sphere(surface2, surface1)
223
+
224
+ # Unknown surface types - assume potentially intersecting
225
+ # Check if materials are the same (consistent even if intersecting)
226
+ consistent = _check_material_consistency(surface1, surface2)
227
+ return SurfaceAnalysisResult(
228
+ relationship=SurfaceRelationship.INTERSECTING,
229
+ details=(
230
+ f"Cannot determine geometric relationship for surface types "
231
+ f"{type(surface1).__name__} and {type(surface2).__name__}. "
232
+ f"Assuming potentially intersecting."
233
+ ),
234
+ materials_consistent=consistent,
235
+ )
236
+
237
+
238
+ def _analyze_plane_pair(
239
+ surface1: Surface,
240
+ surface2: Surface,
241
+ normal1: NDArray[np.float64],
242
+ normal2: NDArray[np.float64],
243
+ ) -> SurfaceAnalysisResult:
244
+ """Analyze relationship between two planes."""
245
+ if are_planes_parallel(normal1, normal2):
246
+ return SurfaceAnalysisResult(
247
+ relationship=SurfaceRelationship.PARALLEL,
248
+ details="Planes are parallel - no intersection.",
249
+ materials_consistent=True,
250
+ )
251
+
252
+ # Non-parallel planes always intersect
253
+ consistent = _check_material_consistency(surface1, surface2)
254
+ angle_rad = np.arccos(np.clip(np.abs(np.dot(normal1, normal2)), 0, 1))
255
+ angle_deg = np.degrees(angle_rad)
256
+
257
+ details = (
258
+ f"Planes intersect at {angle_deg:.1f}° creating 4 quadrants. "
259
+ f"Surface '{surface1.name}' has front={_material_name(surface1.material_front)}, "
260
+ f"back={_material_name(surface1.material_back)}. "
261
+ f"Surface '{surface2.name}' has front={_material_name(surface2.material_front)}, "
262
+ f"back={_material_name(surface2.material_back)}."
263
+ )
264
+
265
+ if not consistent:
266
+ details += (
267
+ " Materials conflict: each quadrant must satisfy constraints from "
268
+ "both surfaces, but the assigned materials are incompatible."
269
+ )
270
+
271
+ return SurfaceAnalysisResult(
272
+ relationship=SurfaceRelationship.INTERSECTING,
273
+ details=details,
274
+ materials_consistent=consistent,
275
+ )
276
+
277
+
278
+ def _analyze_sphere_pair(
279
+ surface1: Surface,
280
+ surface2: Surface,
281
+ center1: NDArray[np.float64],
282
+ center2: NDArray[np.float64],
283
+ ) -> SurfaceAnalysisResult:
284
+ """Analyze relationship between two spheres."""
285
+ if are_spheres_concentric(center1, center2):
286
+ # Concentric spheres create nested regions (like onion layers)
287
+ return SurfaceAnalysisResult(
288
+ relationship=SurfaceRelationship.NESTED,
289
+ details="Spheres are concentric - creates nested regions.",
290
+ materials_consistent=True,
291
+ )
292
+
293
+ # Non-concentric spheres may or may not intersect depending on radii
294
+ # For simplicity, we check material consistency
295
+ consistent = _check_material_consistency(surface1, surface2)
296
+
297
+ return SurfaceAnalysisResult(
298
+ relationship=SurfaceRelationship.INTERSECTING,
299
+ details="Spheres have different centers - may intersect.",
300
+ materials_consistent=consistent,
301
+ )
302
+
303
+
304
+ def _analyze_plane_sphere(
305
+ plane: Surface,
306
+ sphere: Surface,
307
+ ) -> SurfaceAnalysisResult:
308
+ """Analyze relationship between a plane and sphere."""
309
+ # A plane always intersects a sphere (unless the sphere is entirely on one side,
310
+ # but we can't easily determine that without bounds)
311
+ consistent = _check_material_consistency(plane, sphere)
312
+
313
+ return SurfaceAnalysisResult(
314
+ relationship=SurfaceRelationship.INTERSECTING,
315
+ details=(
316
+ f"Plane '{plane.name}' and sphere '{sphere.name}' may intersect. "
317
+ f"Material consistency check required."
318
+ ),
319
+ materials_consistent=consistent,
320
+ )
321
+
322
+
323
+ def _check_material_consistency(
324
+ surface1: Surface,
325
+ surface2: Surface,
326
+ ) -> bool:
327
+ """
328
+ Check if two surfaces have consistent material assignments for intersection.
329
+
330
+ For intersecting surfaces, the front/back model is only consistent if
331
+ all materials are the same OR if the surfaces share materials in a
332
+ compatible way.
333
+
334
+ The simplest consistent case is when both surfaces use the same material
335
+ on both sides (e.g., both are "air" everywhere).
336
+ """
337
+ mat1_front = surface1.material_front
338
+ mat1_back = surface1.material_back
339
+ mat2_front = surface2.material_front
340
+ mat2_back = surface2.material_back
341
+
342
+ # If any material is None, we can't validate
343
+ if any(m is None for m in [mat1_front, mat1_back, mat2_front, mat2_back]):
344
+ return True
345
+
346
+ # Consistent if all four materials are the same
347
+ if mat1_front is mat1_back is mat2_front is mat2_back:
348
+ return True
349
+
350
+ # Consistent if each surface has the same material on both sides
351
+ # (even if different between surfaces - this means no refraction at that surface)
352
+ if mat1_front is mat1_back and mat2_front is mat2_back:
353
+ return True
354
+
355
+ # For intersecting surfaces with different front/back materials on both,
356
+ # we have a conflict: the 4 quadrants can't all be assigned consistently
357
+ #
358
+ # Quadrant analysis:
359
+ # Q1 (front₁ ∩ front₂): must be mat1_front AND mat2_front
360
+ # Q2 (back₁ ∩ front₂): must be mat1_back AND mat2_front
361
+ # Q3 (front₁ ∩ back₂): must be mat1_front AND mat2_back
362
+ # Q4 (back₁ ∩ back₂): must be mat1_back AND mat2_back
363
+ #
364
+ # This is only satisfiable if the materials form a compatible pattern
365
+
366
+ return False
367
+
368
+
369
+ def _material_name(material) -> str:
370
+ """Get a display name for a material."""
371
+ if material is None:
372
+ return "None"
373
+ if hasattr(material, "name"):
374
+ return str(material.name)
375
+ return type(material).__name__
@@ -0,0 +1,91 @@
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
+ Geometry Validation Exceptions
36
+
37
+ Exception classes for geometry validation errors.
38
+ """
39
+
40
+
41
+ class GeometryValidationError(Exception):
42
+ """Base exception for geometry validation errors."""
43
+
44
+ pass
45
+
46
+
47
+ class IntersectingSurfacesError(GeometryValidationError):
48
+ """
49
+ Raised when non-parallel surfaces have inconsistent material assignments.
50
+
51
+ Two non-parallel surfaces divide space into 4 quadrants. With the simple
52
+ front/back material model, all 4 quadrants would need to have the same
53
+ material on each side of both surfaces, which is over-constrained when
54
+ the surfaces have different materials.
55
+
56
+ To resolve this error, either:
57
+ 1. Use parallel surfaces that don't create conflicting regions
58
+ 2. Assign the same material to both sides where conflicts occur
59
+ 3. Use the cell-based API (add_surface_only + add_cell) for explicit
60
+ region-by-region material assignment
61
+
62
+ Attributes
63
+ ----------
64
+ surface1_name : str
65
+ Name of the first conflicting surface.
66
+ surface2_name : str
67
+ Name of the second conflicting surface.
68
+ details : str
69
+ Detailed explanation of the conflict.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ surface1_name: str,
75
+ surface2_name: str,
76
+ details: str = "",
77
+ ):
78
+ self.surface1_name = surface1_name
79
+ self.surface2_name = surface2_name
80
+ self.details = details
81
+
82
+ message = (
83
+ f"Surfaces '{surface1_name}' and '{surface2_name}' intersect "
84
+ f"with inconsistent material assignments.\n"
85
+ f"{details}\n\n"
86
+ f"To resolve this, either:\n"
87
+ f" 1. Use parallel surfaces\n"
88
+ f" 2. Assign the same material to conflicting sides\n"
89
+ f" 3. Use the cell-based API: add_surface_only() + add_cell()"
90
+ )
91
+ super().__init__(message)