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,478 @@
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 Builder
36
+
37
+ Fluent interface for constructing simulation geometries.
38
+ Supports any Surface implementation with named media for material consistency.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import replace
44
+ from typing import Self
45
+
46
+ from ..surfaces import Surface, SurfaceRole
47
+ from ..materials import MaterialField
48
+
49
+ from .geometry import Geometry
50
+ from .cell import Cell, HalfSpace
51
+ from .cell_geometry import CellGeometry
52
+ from .surface_analysis import analyze_surface_pair, SurfaceRelationship
53
+ from .validation import IntersectingSurfacesError
54
+
55
+
56
+ class GeometryBuilder:
57
+ """
58
+ Fluent builder for constructing simulation geometries.
59
+
60
+ Provides a simple interface for registering named media and adding
61
+ surfaces with validation for duplicate names, surface limits, and
62
+ material consistency.
63
+
64
+ Supports two modes:
65
+ 1. Standard mode: Use add_surface(surface, front=, back=) for simple geometries
66
+ 2. Cell mode: Use add_surface_only() + add_cell() for complex geometries
67
+
68
+ Examples
69
+ --------
70
+ Standard mode (parallel planes):
71
+
72
+ >>> from lsurf.geometry import GeometryBuilder
73
+ >>> from lsurf.materials import WATER, ExponentialAtmosphere
74
+ >>> from lsurf.surfaces import SphereSurface, PlaneSurface, SurfaceRole
75
+ >>>
76
+ >>> EARTH_RADIUS = 6.371e6
77
+ >>> atmosphere = ExponentialAtmosphere()
78
+ >>>
79
+ >>> ocean = SphereSurface(
80
+ ... center=(0, 0, -EARTH_RADIUS),
81
+ ... radius=EARTH_RADIUS,
82
+ ... role=SurfaceRole.OPTICAL,
83
+ ... name="ocean",
84
+ ... )
85
+ >>> detector = PlaneSurface(
86
+ ... point=(0, 0, 35000),
87
+ ... normal=(0, 0, 1),
88
+ ... role=SurfaceRole.DETECTOR,
89
+ ... name="detector_35km",
90
+ ... )
91
+ >>>
92
+ >>> geometry = (
93
+ ... GeometryBuilder()
94
+ ... .register_medium("atmosphere", atmosphere)
95
+ ... .register_medium("ocean", WATER)
96
+ ... .set_background("atmosphere")
97
+ ... .add_surface(ocean, front="atmosphere", back="ocean")
98
+ ... .add_detector(detector)
99
+ ... .build()
100
+ ... )
101
+
102
+ Cell mode (non-parallel planes with different materials):
103
+
104
+ >>> plane_x = PlaneSurface(point=(0, 0, 0), normal=(1, 0, 0), ...)
105
+ >>> plane_y = PlaneSurface(point=(0, 0, 0), normal=(0, 1, 0), ...)
106
+ >>>
107
+ >>> geometry = (
108
+ ... GeometryBuilder()
109
+ ... .register_medium("air", AIR)
110
+ ... .register_medium("water", WATER)
111
+ ... .register_medium("glass", GLASS)
112
+ ... .register_medium("vacuum", VACUUM)
113
+ ... .set_background("air")
114
+ ... .add_surface_only(plane_x)
115
+ ... .add_surface_only(plane_y)
116
+ ... .add_cell("air", ("plane_x", True), ("plane_y", True)) # Q1
117
+ ... .add_cell("water", ("plane_x", False), ("plane_y", True)) # Q2
118
+ ... .add_cell("glass", ("plane_x", True), ("plane_y", False)) # Q3
119
+ ... .add_cell("vacuum", ("plane_x", False), ("plane_y", False)) # Q4
120
+ ... .build()
121
+ ... )
122
+ """
123
+
124
+ def __init__(self) -> None:
125
+ self._media: dict[str, MaterialField] = {}
126
+ self._background_medium: str | None = None
127
+ self._surfaces: list[Surface] = []
128
+ self._detectors: list[Surface] = []
129
+ self._surface_names: dict[str, int] = {}
130
+ self._detector_names: dict[str, int] = {}
131
+
132
+ # Cell mode state
133
+ self._cells: list[Cell] = []
134
+ self._cell_mode: bool = False
135
+ self._surface_only_names: set[str] = set() # Surfaces added without materials
136
+
137
+ def register_medium(self, name: str, material: MaterialField) -> Self:
138
+ """
139
+ Register a named medium with its material.
140
+
141
+ A medium is a named reference to a material. Surfaces reference
142
+ media by name, ensuring material consistency when multiple
143
+ surfaces share the same medium.
144
+
145
+ If the same medium name is registered twice, validates that the
146
+ same material instance is used.
147
+
148
+ Parameters
149
+ ----------
150
+ name : str
151
+ Name of the medium.
152
+ material : MaterialField
153
+ Material for this medium.
154
+
155
+ Returns
156
+ -------
157
+ Self
158
+ The builder instance for chaining.
159
+
160
+ Raises
161
+ ------
162
+ ValueError
163
+ If medium already registered with different material instance.
164
+ """
165
+ if name in self._media:
166
+ if self._media[name] is not material:
167
+ raise ValueError(
168
+ f"Medium '{name}' already registered with different material instance"
169
+ )
170
+ self._media[name] = material
171
+ return self
172
+
173
+ def set_background(self, medium_name: str) -> Self:
174
+ """
175
+ Set the background/propagation medium by name.
176
+
177
+ The medium must be registered before calling this method.
178
+
179
+ Parameters
180
+ ----------
181
+ medium_name : str
182
+ Name of the medium to use as background.
183
+
184
+ Returns
185
+ -------
186
+ Self
187
+ The builder instance for chaining.
188
+
189
+ Raises
190
+ ------
191
+ ValueError
192
+ If the medium is not registered.
193
+ """
194
+ if medium_name not in self._media:
195
+ raise ValueError(f"Medium '{medium_name}' not registered")
196
+ self._background_medium = medium_name
197
+ return self
198
+
199
+ def add_surface(
200
+ self,
201
+ surface: Surface,
202
+ front: str,
203
+ back: str,
204
+ ) -> Self:
205
+ """
206
+ Add a surface with materials from named media.
207
+
208
+ Parameters
209
+ ----------
210
+ surface : Surface
211
+ Surface geometry (materials will be replaced).
212
+ front : str
213
+ Medium name for the front side material.
214
+ back : str
215
+ Medium name for the back side material.
216
+
217
+ Returns
218
+ -------
219
+ Self
220
+ The builder instance for chaining.
221
+
222
+ Raises
223
+ ------
224
+ ValueError
225
+ If surface name is duplicate, medium names are not registered,
226
+ or cell mode is active.
227
+ """
228
+ if self._cell_mode:
229
+ raise ValueError(
230
+ "Cannot use add_surface() in cell mode. "
231
+ "Use add_surface_only() instead, or don't call add_cell()."
232
+ )
233
+
234
+ self._validate_name(surface.name)
235
+
236
+ # Lookup materials from media
237
+ front_material = self._get_medium_material(front)
238
+ back_material = self._get_medium_material(back)
239
+
240
+ # Create new surface with correct materials using dataclasses.replace()
241
+ new_surface = replace(
242
+ surface,
243
+ material_front=front_material,
244
+ material_back=back_material,
245
+ )
246
+
247
+ self._surface_names[surface.name] = len(self._surfaces)
248
+ self._surfaces.append(new_surface)
249
+ return self
250
+
251
+ def add_surface_only(self, surface: Surface) -> Self:
252
+ """
253
+ Add a surface without material assignment (for cell mode).
254
+
255
+ Use this with add_cell() to define complex geometries where
256
+ surfaces intersect and the simple front/back model is insufficient.
257
+
258
+ Parameters
259
+ ----------
260
+ surface : Surface
261
+ Surface geometry. Materials should be left as None.
262
+
263
+ Returns
264
+ -------
265
+ Self
266
+ The builder instance for chaining.
267
+
268
+ Raises
269
+ ------
270
+ ValueError
271
+ If surface name is duplicate.
272
+ """
273
+ self._validate_name(surface.name)
274
+
275
+ self._surface_names[surface.name] = len(self._surfaces)
276
+ self._surfaces.append(surface)
277
+ self._surface_only_names.add(surface.name)
278
+ return self
279
+
280
+ def add_cell(
281
+ self,
282
+ medium_name: str,
283
+ *conditions: tuple[str, bool],
284
+ name: str = "",
285
+ ) -> Self:
286
+ """
287
+ Define a cell (region) by half-space intersection.
288
+
289
+ A cell is a region of space defined by being on specific sides
290
+ of multiple surfaces. Each condition specifies a surface and
291
+ which side (front=True means signed_distance > 0).
292
+
293
+ Parameters
294
+ ----------
295
+ medium_name : str
296
+ Name of the medium for this cell.
297
+ *conditions : tuple[str, bool]
298
+ Each condition is (surface_name, front) where:
299
+ - surface_name: Name of a surface added with add_surface_only()
300
+ - front: True for front side (signed_distance > 0),
301
+ False for back side (signed_distance < 0)
302
+ name : str, optional
303
+ Optional human-readable name for the cell.
304
+
305
+ Returns
306
+ -------
307
+ Self
308
+ The builder instance for chaining.
309
+
310
+ Raises
311
+ ------
312
+ ValueError
313
+ If medium is not registered or surface name not found.
314
+
315
+ Examples
316
+ --------
317
+ >>> builder.add_cell("air", ("plane_x", True), ("plane_y", True), name="Q1")
318
+ """
319
+ # Enable cell mode
320
+ self._cell_mode = True
321
+
322
+ # Validate medium exists
323
+ if medium_name not in self._media:
324
+ available = ", ".join(sorted(self._media.keys()))
325
+ raise ValueError(
326
+ f"Medium '{medium_name}' not registered. Available: {available}"
327
+ )
328
+
329
+ # Validate surface names exist
330
+ for surface_name, _front in conditions:
331
+ if surface_name not in self._surface_names:
332
+ available = ", ".join(sorted(self._surface_names.keys()))
333
+ raise ValueError(
334
+ f"Surface '{surface_name}' not found. Available: {available}"
335
+ )
336
+
337
+ # Create half-spaces and cell
338
+ half_spaces = tuple(
339
+ HalfSpace(surface_name=cond[0], front=cond[1]) for cond in conditions
340
+ )
341
+ cell = Cell(half_spaces=half_spaces, medium_name=medium_name, name=name)
342
+ self._cells.append(cell)
343
+
344
+ return self
345
+
346
+ def add_detector(self, detector: Surface) -> Self:
347
+ """
348
+ Add a detector Surface to the geometry.
349
+
350
+ Parameters
351
+ ----------
352
+ detector : Surface
353
+ Any object implementing the Surface protocol.
354
+ Must have a unique `name` attribute.
355
+
356
+ Returns
357
+ -------
358
+ Self
359
+ The builder instance for chaining.
360
+
361
+ Raises
362
+ ------
363
+ ValueError
364
+ If detector name is duplicate.
365
+ """
366
+ self._validate_name(detector.name)
367
+
368
+ self._detector_names[detector.name] = len(self._detectors)
369
+ self._detectors.append(detector)
370
+ return self
371
+
372
+ def _get_medium_material(self, medium_name: str) -> MaterialField:
373
+ """Get material for a medium, raising if not registered."""
374
+ if medium_name not in self._media:
375
+ available = ", ".join(sorted(self._media.keys()))
376
+ raise ValueError(
377
+ f"Medium '{medium_name}' not registered. Available: {available}"
378
+ )
379
+ return self._media[medium_name]
380
+
381
+ def _validate_surface_consistency(self) -> None:
382
+ """
383
+ Validate that surface material assignments are consistent.
384
+
385
+ For non-parallel surfaces with different front/back materials,
386
+ raises IntersectingSurfacesError.
387
+ """
388
+ optical_surfaces = [s for s in self._surfaces if s.role == SurfaceRole.OPTICAL]
389
+
390
+ # Check all pairs
391
+ for i, surf1 in enumerate(optical_surfaces):
392
+ for surf2 in optical_surfaces[i + 1 :]:
393
+ result = analyze_surface_pair(surf1, surf2)
394
+
395
+ if (
396
+ result.relationship == SurfaceRelationship.INTERSECTING
397
+ and not result.materials_consistent
398
+ ):
399
+ raise IntersectingSurfacesError(
400
+ surface1_name=surf1.name,
401
+ surface2_name=surf2.name,
402
+ details=result.details,
403
+ )
404
+
405
+ def build(self, validate: bool = True) -> Geometry | CellGeometry:
406
+ """
407
+ Build the immutable Geometry object.
408
+
409
+ Parameters
410
+ ----------
411
+ validate : bool, optional
412
+ If True (default), validates that surface material assignments
413
+ are consistent. Set to False to skip validation.
414
+
415
+ Returns
416
+ -------
417
+ Geometry or CellGeometry
418
+ Standard Geometry if using add_surface(), or CellGeometry if
419
+ using add_surface_only() + add_cell().
420
+
421
+ Raises
422
+ ------
423
+ ValueError
424
+ If background medium not set or OPTICAL surfaces missing materials.
425
+ IntersectingSurfacesError
426
+ If non-parallel surfaces have conflicting material assignments
427
+ (only when validate=True).
428
+ """
429
+ if self._background_medium is None:
430
+ raise ValueError("Background medium not set. Call set_background() first.")
431
+
432
+ # Cell mode: return CellGeometry
433
+ if self._cell_mode:
434
+ if not self._cells:
435
+ raise ValueError(
436
+ "Cell mode enabled but no cells defined. "
437
+ "Call add_cell() at least once."
438
+ )
439
+
440
+ return CellGeometry(
441
+ surfaces=tuple(self._surfaces),
442
+ detectors=tuple(self._detectors),
443
+ background_material=self._media[self._background_medium],
444
+ media=dict(self._media),
445
+ surface_names=dict(self._surface_names),
446
+ detector_names=dict(self._detector_names),
447
+ cells=tuple(self._cells),
448
+ )
449
+
450
+ # Standard mode: validate and return Geometry
451
+ # Validate all OPTICAL surfaces have materials
452
+ for surface in self._surfaces:
453
+ if surface.role == SurfaceRole.OPTICAL:
454
+ if surface.material_front is None or surface.material_back is None:
455
+ raise ValueError(
456
+ f"OPTICAL surface '{surface.name}' missing materials. "
457
+ "Use add_surface(surface, front='medium', back='medium')."
458
+ )
459
+
460
+ # Validate surface consistency
461
+ if validate:
462
+ self._validate_surface_consistency()
463
+
464
+ return Geometry(
465
+ surfaces=tuple(self._surfaces),
466
+ detectors=tuple(self._detectors),
467
+ background_material=self._media[self._background_medium],
468
+ media=dict(self._media),
469
+ surface_names=dict(self._surface_names),
470
+ detector_names=dict(self._detector_names),
471
+ )
472
+
473
+ def _validate_name(self, name: str) -> None:
474
+ """Validate that a name is unique across surfaces and detectors."""
475
+ if name in self._surface_names:
476
+ raise ValueError(f"Duplicate surface name: '{name}'")
477
+ if name in self._detector_names:
478
+ raise ValueError(f"Duplicate detector name: '{name}'")
lsurf/geometry/cell.py ADDED
@@ -0,0 +1,228 @@
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
+ Cell-Based Geometry Definition
36
+
37
+ Provides explicit region-by-region material assignment for complex geometries
38
+ where the simple front/back model is insufficient.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass
44
+ from typing import TYPE_CHECKING
45
+
46
+ import numpy as np
47
+
48
+ if TYPE_CHECKING:
49
+ from numpy.typing import NDArray
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class HalfSpace:
54
+ """
55
+ Defines one side of a surface (half-space).
56
+
57
+ A half-space is the region on one side of a surface, identified by
58
+ whether the signed distance is positive (front) or negative (back).
59
+
60
+ Parameters
61
+ ----------
62
+ surface_name : str
63
+ Name of the surface that defines this half-space.
64
+ front : bool
65
+ If True, the half-space is where signed_distance > 0 (front side).
66
+ If False, the half-space is where signed_distance < 0 (back side).
67
+
68
+ Examples
69
+ --------
70
+ >>> # Front side of plane_x
71
+ >>> hs1 = HalfSpace("plane_x", front=True)
72
+ >>> # Back side of plane_y
73
+ >>> hs2 = HalfSpace("plane_y", front=False)
74
+ """
75
+
76
+ surface_name: str
77
+ front: bool
78
+
79
+ def __repr__(self) -> str:
80
+ side = "front" if self.front else "back"
81
+ return f"HalfSpace({self.surface_name!r}, {side})"
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Cell:
86
+ """
87
+ A region of space defined by the intersection of half-spaces.
88
+
89
+ A cell represents a region where all half-space conditions are satisfied
90
+ simultaneously. The material/medium assigned to this cell applies to
91
+ all points within the region.
92
+
93
+ Parameters
94
+ ----------
95
+ half_spaces : tuple of HalfSpace
96
+ Half-space conditions that must all be satisfied for a point
97
+ to be in this cell.
98
+ medium_name : str
99
+ Name of the medium (material) for this cell.
100
+ name : str, optional
101
+ Optional human-readable name for the cell.
102
+
103
+ Examples
104
+ --------
105
+ >>> # Define a cell for the region where x > 0 and y > 0
106
+ >>> cell = Cell(
107
+ ... half_spaces=(
108
+ ... HalfSpace("plane_x", front=True),
109
+ ... HalfSpace("plane_y", front=True),
110
+ ... ),
111
+ ... medium_name="air",
112
+ ... name="Q1",
113
+ ... )
114
+ """
115
+
116
+ half_spaces: tuple[HalfSpace, ...]
117
+ medium_name: str
118
+ name: str = ""
119
+
120
+ def contains(
121
+ self,
122
+ signed_distances: dict[str, NDArray[np.float64]],
123
+ ) -> NDArray[np.bool_]:
124
+ """
125
+ Determine which positions are contained in this cell.
126
+
127
+ A position is in the cell if it satisfies all half-space conditions:
128
+ - For HalfSpace(surface, front=True): signed_distance[surface] > 0
129
+ - For HalfSpace(surface, front=False): signed_distance[surface] < 0
130
+
131
+ Parameters
132
+ ----------
133
+ signed_distances : dict[str, NDArray]
134
+ Mapping from surface name to signed distance arrays.
135
+ All arrays must have the same shape (N,).
136
+
137
+ Returns
138
+ -------
139
+ NDArray[np.bool_], shape (N,)
140
+ True for positions that are inside this cell.
141
+
142
+ Raises
143
+ ------
144
+ KeyError
145
+ If a required surface name is not in signed_distances.
146
+ """
147
+ if not self.half_spaces:
148
+ # Empty half-spaces means no constraints - everything is inside
149
+ # Get shape from any signed distance array
150
+ for arr in signed_distances.values():
151
+ return np.ones(arr.shape, dtype=np.bool_)
152
+ return np.array([], dtype=np.bool_)
153
+
154
+ # Start with all True
155
+ first_hs = self.half_spaces[0]
156
+ sd = signed_distances[first_hs.surface_name]
157
+ mask = sd > 0 if first_hs.front else sd < 0
158
+
159
+ # AND with remaining half-spaces
160
+ for hs in self.half_spaces[1:]:
161
+ sd = signed_distances[hs.surface_name]
162
+ condition = sd > 0 if hs.front else sd < 0
163
+ mask = mask & condition
164
+
165
+ return mask
166
+
167
+ def __repr__(self) -> str:
168
+ if self.name:
169
+ return f"Cell({self.name!r}, medium={self.medium_name!r})"
170
+ conditions = ", ".join(repr(hs) for hs in self.half_spaces)
171
+ return f"Cell([{conditions}], medium={self.medium_name!r})"
172
+
173
+
174
+ def create_half_space(surface_name: str, front: bool) -> HalfSpace:
175
+ """
176
+ Create a HalfSpace from surface name and side.
177
+
178
+ Convenience function for creating half-spaces.
179
+
180
+ Parameters
181
+ ----------
182
+ surface_name : str
183
+ Name of the surface.
184
+ front : bool
185
+ True for front side (signed_distance > 0),
186
+ False for back side (signed_distance < 0).
187
+
188
+ Returns
189
+ -------
190
+ HalfSpace
191
+ The created half-space.
192
+ """
193
+ return HalfSpace(surface_name=surface_name, front=front)
194
+
195
+
196
+ def create_cell(
197
+ medium_name: str,
198
+ *conditions: tuple[str, bool],
199
+ name: str = "",
200
+ ) -> Cell:
201
+ """
202
+ Create a Cell from medium name and half-space conditions.
203
+
204
+ Convenience function for creating cells with a cleaner syntax.
205
+
206
+ Parameters
207
+ ----------
208
+ medium_name : str
209
+ Name of the medium for this cell.
210
+ *conditions : tuple[str, bool]
211
+ Each condition is (surface_name, front) defining a half-space.
212
+ name : str, optional
213
+ Optional name for the cell.
214
+
215
+ Returns
216
+ -------
217
+ Cell
218
+ The created cell.
219
+
220
+ Examples
221
+ --------
222
+ >>> # Q1: front of plane_x AND front of plane_y
223
+ >>> cell = create_cell("air", ("plane_x", True), ("plane_y", True), name="Q1")
224
+ """
225
+ half_spaces = tuple(
226
+ HalfSpace(surface_name=cond[0], front=cond[1]) for cond in conditions
227
+ )
228
+ return Cell(half_spaces=half_spaces, medium_name=medium_name, name=name)