• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openmc-dev / openmc / 12776996362

14 Jan 2025 09:49PM UTC coverage: 84.938% (+0.2%) from 84.729%
12776996362

Pull #3133

github

web-flow
Merge 0495246d9 into 549cc0973
Pull Request #3133: Kinetics parameters using Iterated Fission Probability

318 of 330 new or added lines in 10 files covered. (96.36%)

1658 existing lines in 66 files now uncovered.

50402 of 59340 relevant lines covered (84.94%)

33987813.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.57
/openmc/model/surface_composite.py
1
from __future__ import annotations
12✔
2
from abc import ABC, abstractmethod
12✔
3
from collections.abc import Iterable, Sequence
12✔
4
from copy import copy
12✔
5
from functools import partial
12✔
6
from math import sqrt, pi, sin, cos, isclose
12✔
7
from numbers import Real
12✔
8
import warnings
12✔
9
import operator
12✔
10

11
import numpy as np
12✔
12
from scipy.spatial import ConvexHull, Delaunay
12✔
13

14
import openmc
12✔
15
from openmc.checkvalue import (check_greater_than, check_value, check_less_than,
12✔
16
                               check_iterable_type, check_length, check_type)
17

18

19
class CompositeSurface(ABC):
12✔
20
    """Multiple primitive surfaces combined into a composite surface"""
21

22
    def translate(self, vector, inplace=False):
12✔
23
        surf = self if inplace else copy(self)
12✔
24
        for name in self._surface_names:
12✔
25
            s = getattr(surf, name)
12✔
26
            setattr(surf, name, s.translate(vector, inplace))
12✔
27
        return surf
12✔
28

29
    def rotate(self, rotation, pivot=(0., 0., 0.), order='xyz', inplace=False):
12✔
30
        surf = self if inplace else copy(self)
×
31
        for name in self._surface_names:
×
32
            s = getattr(surf, name)
×
33
            setattr(surf, name, s.rotate(rotation, pivot, order, inplace))
×
UNCOV
34
        return surf
×
35

36
    @property
12✔
37
    def boundary_type(self):
12✔
38
        return getattr(self, self._surface_names[0]).boundary_type
12✔
39

40
    @boundary_type.setter
12✔
41
    def boundary_type(self, boundary_type):
12✔
42
        # Set boundary type on underlying surfaces, but not for ambiguity plane
43
        # on one-sided cones
44
        classes = (XConeOneSided, YConeOneSided, ZConeOneSided, Vessel)
12✔
45
        for name in self._surface_names:
12✔
46
            if isinstance(self, classes) and name.startswith('plane'):
12✔
47
                continue
12✔
48
            getattr(self, name).boundary_type = boundary_type
12✔
49

50
    def __repr__(self):
12✔
51
        return f"<{type(self).__name__} at 0x{id(self):x}>"
12✔
52

53
    @property
12✔
54
    @abstractmethod
12✔
55
    def _surface_names(self):
12✔
56
        """Iterable of attribute names corresponding to underlying surfaces."""
57

58
    @abstractmethod
12✔
59
    def __neg__(self) -> openmc.Region:
12✔
60
        """Return the negative half-space of the composite surface."""
61

62
    def __pos__(self) -> openmc.Region:
12✔
63
        """Return the positive half-space of the composite surface."""
64
        return ~(-self)
12✔
65

66

67
class CylinderSector(CompositeSurface):
12✔
68
    """Infinite cylindrical sector composite surface.
69

70
    A cylinder sector is composed of two cylindrical and two planar surfaces.
71
    The cylindrical surfaces are concentric, and the planar surfaces intersect
72
    the central axis of the cylindrical surfaces.
73

74
    This class acts as a proper surface, meaning that unary `+` and `-`
75
    operators applied to it will produce a half-space. The negative
76
    side is defined to be the region inside of the cylinder sector.
77

78
    .. versionadded:: 0.13.1
79

80
    Parameters
81
    ----------
82
    r1 : float
83
        Inner radius of sector. Must be less than r2.
84
    r2 : float
85
        Outer radius of sector. Must be greater than r1.
86
    theta1 : float
87
        Clockwise-most bound of sector in degrees. Assumed to be in the
88
        counterclockwise direction with respect to the first basis axis
89
        (+y, +z, or +x). Must be less than :attr:`theta2`.
90
    theta2 : float
91
        Counterclockwise-most bound of sector in degrees. Assumed to be in the
92
        counterclockwise direction with respect to the first basis axis
93
        (+y, +z, or +x). Must be greater than :attr:`theta1`.
94
    center : iterable of float
95
        Coordinate for central axes of cylinders in the (y, z), (x, z), or (x, y)
96
        basis. Defaults to (0,0).
97
    axis : {'x', 'y', 'z'}
98
        Central axis of the cylinders defining the inner and outer surfaces of
99
        the sector. Defaults to 'z'.
100
    **kwargs : dict
101
        Keyword arguments passed to the :class:`Cylinder` and
102
        :class:`Plane` constructors.
103

104
    Attributes
105
    ----------
106
    outer_cyl : openmc.ZCylinder, openmc.YCylinder, or openmc.XCylinder
107
        Outer cylinder surface.
108
    inner_cyl : openmc.ZCylinder, openmc.YCylinder, or openmc.XCylinder
109
        Inner cylinder surface.
110
    plane1 : openmc.Plane
111
        Plane at angle :math:`\\theta_1` relative to the first basis axis.
112
    plane2 : openmc.Plane
113
        Plane at angle :math:`\\theta_2` relative to the first basis axis.
114

115
    """
116

117
    _surface_names = ('outer_cyl', 'inner_cyl', 'plane1', 'plane2')
12✔
118

119
    def __init__(self,
12✔
120
                 r1,
121
                 r2,
122
                 theta1,
123
                 theta2,
124
                 center=(0., 0.),
125
                 axis='z',
126
                 **kwargs):
127

128
        if r2 <= r1:
12✔
129
            raise ValueError('r2 must be greater than r1.')
12✔
130

131
        if theta2 <= theta1:
12✔
132
            raise ValueError('theta2 must be greater than theta1.')
12✔
133

134
        phi1 = pi / 180 * theta1
12✔
135
        phi2 = pi / 180 * theta2
12✔
136

137
        # Coords for axis-perpendicular planes
138
        p1 = np.array([center[0], center[1], 1.])
12✔
139

140
        p2_plane1 = np.array([r1 * cos(phi1) + center[0], r1 * sin(phi1) + center[1], 0.])
12✔
141
        p3_plane1 = np.array([r2 * cos(phi1) + center[0], r2 * sin(phi1) + center[1], 0.])
12✔
142

143
        p2_plane2 = np.array([r1 * cos(phi2) + center[0], r1 * sin(phi2)+ center[1], 0.])
12✔
144
        p3_plane2 = np.array([r2 * cos(phi2) + center[0], r2 * sin(phi2)+ center[1], 0.])
12✔
145

146
        points = [p1, p2_plane1, p3_plane1, p2_plane2, p3_plane2]
12✔
147
        if axis == 'z':
12✔
148
            coord_map = [0, 1, 2]
12✔
149
            self.inner_cyl = openmc.ZCylinder(*center, r1, **kwargs)
12✔
150
            self.outer_cyl = openmc.ZCylinder(*center, r2, **kwargs)
12✔
151
        elif axis == 'y':
12✔
152
            coord_map = [0, 2, 1]
12✔
153
            self.inner_cyl = openmc.YCylinder(*center, r1, **kwargs)
12✔
154
            self.outer_cyl = openmc.YCylinder(*center, r2, **kwargs)
12✔
155
        elif axis == 'x':
12✔
156
            coord_map = [2, 0, 1]
12✔
157
            self.inner_cyl = openmc.XCylinder(*center, r1, **kwargs)
12✔
158
            self.outer_cyl = openmc.XCylinder(*center, r2, **kwargs)
12✔
159

160
        # Reorder the points to correspond to the correct central axis
161
        for p in points:
12✔
162
            p[:] = p[coord_map]
12✔
163

164
        self.plane1 = openmc.Plane.from_points(p1, p2_plane1, p3_plane1,
12✔
165
                                               **kwargs)
166
        self.plane2 = openmc.Plane.from_points(p1, p2_plane2, p3_plane2,
12✔
167
                                               **kwargs)
168

169
    @classmethod
12✔
170
    def from_theta_alpha(cls,
12✔
171
                         r1,
172
                         r2,
173
                         theta,
174
                         alpha,
175
                         center = (0.,0.),
176
                         axis='z',
177
                         **kwargs):
178
        r"""Alternate constructor for :class:`CylinderSector`. Returns a
179
        :class:`CylinderSector` object based on a central angle :math:`\theta`
180
        and an angular offset :math:`\alpha`. Note that
181
        :math:`\theta_1 = \alpha` and :math:`\theta_2 = \alpha + \theta`.
182

183
        Parameters
184
        ----------
185
        r1 : float
186
            Inner radius of sector. Must be less than r2.
187
        r2 : float
188
            Outer radius of sector. Must be greater than r1.
189
        theta : float
190
            Central angle, :math:`\theta`, of the sector in degrees. Must be
191
            greater that 0 and less than 360.
192
        alpha : float
193
            Angular offset, :math:`\alpha`, of sector in degrees.
194
            The offset is in the counter-clockwise direction
195
            with respect to the first basis axis (+y, +z, or +x). Note that
196
            negative values translate to an offset in the clockwise direction.
197
        center : iterable of float
198
            Coordinate for central axes of cylinders in the (y, z), (x, z), or
199
            (x, y) basis. Defaults to (0,0).
200
        axis : {'x', 'y', 'z'}
201
            Central axis of the cylinders defining the inner and outer surfaces
202
            of the sector. Defaults to 'z'.
203
        **kwargs : dict
204
            Keyword arguments passed to the :class:`Cylinder` and
205
            :class:`Plane` constructors.
206

207
        Returns
208
        -------
209
        CylinderSector
210
            CylinderSector with the given central angle at the given
211
            offset.
212
        """
213
        if theta >= 360. or theta <= 0:
12✔
214
            raise ValueError('theta must be less than 360 and greater than 0.')
12✔
215

216
        theta1 = alpha
12✔
217
        theta2 = alpha + theta
12✔
218

219
        return cls(r1, r2, theta1, theta2, center=center, axis=axis, **kwargs)
12✔
220

221
    def __neg__(self):
12✔
222
        if isinstance(self.inner_cyl, openmc.YCylinder):
12✔
223
            return -self.outer_cyl & +self.inner_cyl & +self.plane1 & -self.plane2
12✔
224
        else:
225
            return -self.outer_cyl & +self.inner_cyl & -self.plane1 & +self.plane2
12✔
226

227
    def __pos__(self):
12✔
228
        if isinstance(self.inner_cyl, openmc.YCylinder):
12✔
229
            return +self.outer_cyl | -self.inner_cyl | -self.plane1 | +self.plane2
12✔
230
        else:
231
            return +self.outer_cyl | -self.inner_cyl | +self.plane1 | -self.plane2
12✔
232

233

234
class IsogonalOctagon(CompositeSurface):
12✔
235
    r"""Infinite isogonal octagon composite surface
236

237
    An isogonal octagon is composed of eight planar surfaces. The prism is
238
    parallel to the x, y, or z axis. The remaining two axes (y and z, x and z,
239
    or x and y) serve as a basis for constructing the surfaces. Two surfaces
240
    are parallel to the first basis axis, two surfaces are parallel
241
    to the second basis axis, and the remaining four surfaces intersect both
242
    basis axes at 45 degree angles.
243

244
    This class acts as a proper surface, meaning that unary `+` and `-`
245
    operators applied to it will produce a half-space. The negative side is
246
    defined to be the region inside of the octagonal prism.
247

248
    .. versionadded:: 0.13.1
249

250
    Parameters
251
    ----------
252
    center : iterable of float
253
        Coordinate for the central axis of the octagon in the
254
        (y, z), (x, z), or (x, y) basis depending on the axis parameter.
255
    r1 : float
256
        Half-width of octagon across its basis axis-parallel sides in units
257
        of cm. Must be less than :math:`r_2\sqrt{2}`.
258
    r2 : float
259
        Half-width of octagon across its basis axis intersecting sides in
260
        units of cm. Must be less than than :math:`r_1\sqrt{2}`.
261
    axis : {'x', 'y', 'z'}
262
        Central axis of octagon. Defaults to 'z'
263
    **kwargs
264
        Keyword arguments passed to underlying plane classes
265

266
    Attributes
267
    ----------
268
    top : openmc.ZPlane, openmc.XPlane, or openmc.YPlane
269
        Top planar surface of octagon
270
    bottom : openmc.ZPlane, openmc.XPlane, or openmc.YPlane
271
        Bottom planar surface of octagon
272
    right : openmc.YPlane, openmc.ZPlane, or openmc.XPlane
273
        Right planar surface of octagon
274
    left : openmc.YPlane, openmc.ZPlane, or openmc.XPlane
275
        Left planar surface of octagon
276
    upper_right : openmc.Plane
277
        Upper right planar surface of octagon
278
    lower_right : openmc.Plane
279
        Lower right planar surface of octagon
280
    lower_left : openmc.Plane
281
        Lower left planar surface of octagon
282
    upper_left : openmc.Plane
283
        Upper left planar surface of octagon
284

285
    """
286

287
    _surface_names = ('top', 'bottom',
12✔
288
                      'upper_right', 'lower_left',
289
                      'right', 'left',
290
                      'lower_right', 'upper_left')
291

292
    def __init__(self, center, r1, r2, axis='z', **kwargs):
12✔
293
        c1, c2 = center
12✔
294

295
        # Coordinates for axis-perpendicular planes
296
        cright = c1 + r1
12✔
297
        cleft = c1 - r1
12✔
298

299
        ctop = c2 + r1
12✔
300
        cbottom = c2 - r1
12✔
301

302
        # Side lengths
303
        if r2 > r1 * sqrt(2):
12✔
304
            raise ValueError('r2 is greater than sqrt(2) * r1. Octagon' +
×
305
                             ' may be erroneous.')
306
        if r1 > r2 * sqrt(2):
12✔
UNCOV
307
            raise ValueError('r1 is greater than sqrt(2) * r2. Octagon' +
×
308
                             ' may be erroneous.')
309

310
        L_basis_ax = (r2 * sqrt(2) - r1)
12✔
311

312
        # Coordinates for quadrant planes
313
        p1_ur = np.array([L_basis_ax, r1, 0.])
12✔
314
        p2_ur = np.array([r1, L_basis_ax, 0.])
12✔
315
        p3_ur = np.array([r1, L_basis_ax, 1.])
12✔
316

317
        p1_lr = np.array([r1, -L_basis_ax, 0.])
12✔
318
        p2_lr = np.array([L_basis_ax, -r1, 0.])
12✔
319
        p3_lr = np.array([L_basis_ax, -r1, 1.])
12✔
320

321
        p1_ll = -p1_ur
12✔
322
        p2_ll = -p2_ur
12✔
323
        p3_ll = -p3_ur
12✔
324

325
        p1_ul = -p1_lr
12✔
326
        p2_ul = -p2_lr
12✔
327
        p3_ul = -p3_lr
12✔
328

329
        points = [p1_ur, p2_ur, p3_ur, p1_lr, p2_lr, p3_lr,
12✔
330
                  p1_ll, p2_ll, p3_ll, p1_ul, p2_ul, p3_ul]
331

332
        # Orientation specific variables
333
        if axis == 'z':
12✔
334
            coord_map = [0, 1, 2]
12✔
335
            self.top = openmc.YPlane(ctop, **kwargs)
12✔
336
            self.bottom = openmc.YPlane(cbottom, **kwargs)
12✔
337
            self.right = openmc.XPlane(cright, **kwargs)
12✔
338
            self.left = openmc.XPlane(cleft, **kwargs)
12✔
339
        elif axis == 'y':
12✔
340
            coord_map = [0, 2, 1]
12✔
341
            self.top = openmc.ZPlane(ctop, **kwargs)
12✔
342
            self.bottom = openmc.ZPlane(cbottom, **kwargs)
12✔
343
            self.right = openmc.XPlane(cright, **kwargs)
12✔
344
            self.left = openmc.XPlane(cleft, **kwargs)
12✔
345
        elif axis == 'x':
12✔
346
            coord_map = [2, 0, 1]
12✔
347
            self.top = openmc.ZPlane(ctop, **kwargs)
12✔
348
            self.bottom = openmc.ZPlane(cbottom, **kwargs)
12✔
349
            self.right = openmc.YPlane(cright, **kwargs)
12✔
350
            self.left = openmc.YPlane(cleft, **kwargs)
12✔
351
        self.axis = axis
12✔
352

353
        # Put our coordinates in (x,y,z) order and add the offset
354
        for p in points:
12✔
355
            p[0] += c1
12✔
356
            p[1] += c2
12✔
357
            p[:] = p[coord_map]
12✔
358

359
        self.upper_right = openmc.Plane.from_points(p1_ur, p2_ur, p3_ur,
12✔
360
                                                    **kwargs)
361
        self.lower_right = openmc.Plane.from_points(p1_lr, p2_lr, p3_lr,
12✔
362
                                                    **kwargs)
363
        self.lower_left = openmc.Plane.from_points(p1_ll, p2_ll, p3_ll,
12✔
364
                                                   **kwargs)
365
        self.upper_left = openmc.Plane.from_points(p1_ul, p2_ul, p3_ul,
12✔
366
                                                   **kwargs)
367

368
    def __neg__(self):
12✔
369
        if self.axis == 'y':
12✔
370
            region = -self.top & +self.bottom & -self.right & +self.left & \
12✔
371
                -self.upper_right & -self.lower_right & +self.lower_left & \
372
                +self.upper_left
373
        else:
374
            region = -self.top & +self.bottom & -self.right & +self.left & \
12✔
375
                +self.upper_right & +self.lower_right & -self.lower_left & \
376
                -self.upper_left
377

378
        return region
12✔
379

380
    def __pos__(self):
12✔
381
        if self.axis == 'y':
12✔
382
            region = +self.top | -self.bottom | +self.right | -self.left | \
12✔
383
                +self.upper_right | +self.lower_right | -self.lower_left | \
384
                -self.upper_left
385
        else:
386
            region = +self.top | -self.bottom | +self.right | -self.left | \
12✔
387
                -self.upper_right | -self.lower_right | +self.lower_left | \
388
                +self.upper_left
389
        return region
12✔
390

391

392
class RightCircularCylinder(CompositeSurface):
12✔
393
    """Right circular cylinder composite surface
394

395
    A right circular cylinder is composed of a cylinder and two planar surface
396
    perpendicular to the axis of the cylinder. This class acts as a proper
397
    surface, meaning that unary `+` and `-` operators applied to it will produce
398
    a half-space. The negative side is defined to be the region inside of the
399
    right circular cylinder.
400

401
    .. versionadded:: 0.12
402

403
    Parameters
404
    ----------
405
    center_base : iterable of float
406
        Cartesian coordinate of the center of the base of the cylinder
407
    height : float
408
        Height of the cylinder
409
    radius : float
410
        Radius of the cylinder
411
    axis : {'x', 'y', 'z'}
412
        Axis of the cylinder
413
    upper_fillet_radius : float
414
        Upper edge fillet radius in [cm].
415
    lower_fillet_radius : float
416
        Lower edge fillet radius in [cm].
417
    **kwargs
418
        Keyword arguments passed to underlying cylinder and plane classes
419

420
    Attributes
421
    ----------
422
    cyl : openmc.Cylinder
423
        Underlying cylinder surface
424
    bottom : openmc.Plane
425
        Bottom planar surface of the cylinder
426
    top : openmc.Plane
427
        Top planar surface of the cylinder
428
    upper_fillet_torus : openmc.Torus
429
        Surface that creates the filleted edge for the upper end of the
430
        cylinder. Only present if :attr:`upper_fillet_radius` is set.
431
    upper_fillet_cylinder : openmc.Cylinder
432
        Surface that bounds :attr:`upper_fillet_torus` radially. Only present
433
        if :attr:`upper_fillet_radius` is set.
434
    upper_fillet_plane : openmc.Plane
435
        Surface that bounds :attr:`upper_fillet_torus` axially. Only present if
436
        :attr:`upper_fillet_radius` is set.
437
    lower_fillet_torus : openmc.Torus
438
        Surface that creates the filleted edge for the lower end of the
439
        cylinder. Only present if :attr:`lower_fillet_radius` is set.
440
    lower_fillet_cylinder : openmc.Cylinder
441
        Surface that bounds :attr:`lower_fillet_torus` radially. Only present
442
        if :attr:`lower_fillet_radius` is set.
443
    lower_fillet_plane : openmc.Plane
444
        Surface that bounds :attr:`lower_fillet_torus` axially. Only present if
445
        :attr:`lower_fillet_radius` is set.
446

447
    """
448
    _surface_names = ('cyl', 'bottom', 'top')
12✔
449

450
    def __init__(self, center_base, height, radius, axis='z',
12✔
451
                 upper_fillet_radius=0., lower_fillet_radius=0., **kwargs):
452
        cx, cy, cz = center_base
12✔
453
        check_greater_than('cylinder height', height, 0.0)
12✔
454
        check_greater_than('cylinder radius', radius, 0.0)
12✔
455
        check_value('cylinder axis', axis, ('x', 'y', 'z'))
12✔
456
        check_type('upper_fillet_radius', upper_fillet_radius, float)
12✔
457
        check_less_than('upper_fillet_radius', upper_fillet_radius,
12✔
458
                        radius, equality=True)
459
        check_type('lower_fillet_radius', lower_fillet_radius, float)
12✔
460
        check_less_than('lower_fillet_radius', lower_fillet_radius,
12✔
461
                        radius, equality=True)
462

463
        if axis == 'x':
12✔
464
            self.cyl = openmc.XCylinder(y0=cy, z0=cz, r=radius, **kwargs)
12✔
465
            self.bottom = openmc.XPlane(x0=cx, **kwargs)
12✔
466
            self.top = openmc.XPlane(x0=cx + height, **kwargs)
12✔
467
            x1, x2 = 'y', 'z'
12✔
468
            axcoord, axcoord1, axcoord2 = 0, 1, 2
12✔
469
        elif axis == 'y':
12✔
470
            self.cyl = openmc.YCylinder(x0=cx, z0=cz, r=radius, **kwargs)
12✔
471
            self.bottom = openmc.YPlane(y0=cy, **kwargs)
12✔
472
            self.top = openmc.YPlane(y0=cy + height, **kwargs)
12✔
473
            x1, x2 = 'x', 'z'
12✔
474
            axcoord, axcoord1, axcoord2 = 1, 0, 2
12✔
475
        elif axis == 'z':
12✔
476
            self.cyl = openmc.ZCylinder(x0=cx, y0=cy, r=radius, **kwargs)
12✔
477
            self.bottom = openmc.ZPlane(z0=cz, **kwargs)
12✔
478
            self.top = openmc.ZPlane(z0=cz + height, **kwargs)
12✔
479
            x1, x2 = 'x', 'y'
12✔
480
            axcoord, axcoord1, axcoord2 = 2, 0, 1
12✔
481

482
        def _create_fillet_objects(axis_args, height, center_base, radius, fillet_radius, pos='upper'):
12✔
483
            axis, x1, x2, axcoord, axcoord1, axcoord2 = axis_args
12✔
484
            fillet_ext = height / 2 - fillet_radius
12✔
485
            sign = 1
12✔
486
            if pos == 'lower':
12✔
487
                sign = -1
12✔
488
            coord = center_base[axcoord] + (height / 2) + sign * fillet_ext
12✔
489

490
            # cylinder
491
            cyl_name = f'{pos}_min'
12✔
492
            cylinder_args = {
12✔
493
                x1 + '0': center_base[axcoord1],
494
                x2 + '0': center_base[axcoord2],
495
                'r': radius - fillet_radius
496
            }
497
            cls = getattr(openmc, f'{axis.upper()}Cylinder')
12✔
498
            cyl = cls(name=f'{cyl_name} {axis}', **cylinder_args)
12✔
499

500
            #torus
501
            tor_name = f'{axis} {pos}'
12✔
502
            tor_args = {
12✔
503
                'a': radius - fillet_radius,
504
                'b': fillet_radius,
505
                'c': fillet_radius,
506
                x1 + '0': center_base[axcoord1],
507
                x2 + '0': center_base[axcoord2],
508
                axis + '0': coord
509
            }
510
            cls = getattr(openmc, f'{axis.upper()}Torus')
12✔
511
            torus = cls(name=tor_name, **tor_args)
12✔
512

513
            # plane
514
            p_name = f'{pos} ext'
12✔
515
            p_args = {axis + '0': coord}
12✔
516
            cls = getattr(openmc, f'{axis.upper()}Plane')
12✔
517
            plane = cls(name=p_name, **p_args)
12✔
518

519
            return cyl, torus, plane
12✔
520

521
        if upper_fillet_radius > 0. or lower_fillet_radius > 0.:
12✔
522
            if 'boundary_type' in kwargs:
12✔
UNCOV
523
                if kwargs['boundary_type'] == 'periodic':
×
UNCOV
524
                    raise ValueError('Periodic boundary conditions not permitted when '
×
525
                                     'rounded corners are used.')
526

527
            axis_args = (axis, x1, x2, axcoord, axcoord1, axcoord2)
12✔
528
            if upper_fillet_radius > 0.:
12✔
529
                cylinder, torus, plane = _create_fillet_objects(
12✔
530
                    axis_args, height, center_base, radius, upper_fillet_radius)
531
                self.upper_fillet_cylinder = cylinder
12✔
532
                self.upper_fillet_torus = torus
12✔
533
                self.upper_fillet_plane = plane
12✔
534
                self._surface_names += ('upper_fillet_cylinder',
12✔
535
                                        'upper_fillet_torus',
536
                                        'upper_fillet_plane')
537

538
            if lower_fillet_radius > 0.:
12✔
539
                cylinder, torus, plane = _create_fillet_objects(
12✔
540
                    axis_args, height, center_base, radius, lower_fillet_radius,
541
                    pos='lower'
542
                )
543
                self.lower_fillet_cylinder = cylinder
12✔
544
                self.lower_fillet_torus = torus
12✔
545
                self.lower_fillet_plane = plane
12✔
546

547
                self._surface_names += ('lower_fillet_cylinder',
12✔
548
                                        'lower_fillet_torus',
549
                                        'lower_fillet_plane')
550

551
    def _get_fillet(self):
12✔
552
        upper_fillet = self._get_upper_fillet()
12✔
553
        lower_fillet = self._get_lower_fillet()
12✔
554
        has_upper_fillet = upper_fillet is not None
12✔
555
        has_lower_fillet = lower_fillet is not None
12✔
556
        if has_lower_fillet and has_upper_fillet:
12✔
557
            fillet = lower_fillet | upper_fillet
12✔
558
        elif has_upper_fillet and not has_lower_fillet:
12✔
UNCOV
559
            fillet = upper_fillet
×
560
        elif not has_upper_fillet and has_lower_fillet:
12✔
UNCOV
561
            fillet = lower_fillet
×
562
        else:
563
            fillet = None
12✔
564
        return fillet
12✔
565

566
    def _get_upper_fillet(self):
12✔
567
        has_upper_fillet = hasattr(self, 'upper_fillet_plane')
12✔
568
        if has_upper_fillet:
12✔
569
            upper_fillet = +self.upper_fillet_cylinder & +self.upper_fillet_torus & +self.upper_fillet_plane
12✔
570
        else:
571
            upper_fillet = None
12✔
572
        return upper_fillet
12✔
573

574
    def _get_lower_fillet(self):
12✔
575
        has_lower_fillet = hasattr(self, 'lower_fillet_plane')
12✔
576
        if has_lower_fillet:
12✔
577
            lower_fillet = +self.lower_fillet_cylinder & +self.lower_fillet_torus & -self.lower_fillet_plane
12✔
578
        else:
579
            lower_fillet = None
12✔
580
        return lower_fillet
12✔
581

582
    def __neg__(self):
12✔
583
        prism = -self.cyl & +self.bottom & -self.top
12✔
584
        fillet = self._get_fillet()
12✔
585
        if fillet is not None:
12✔
586
            prism = prism & ~fillet
12✔
587
        return prism
12✔
588

589
    def __pos__(self):
12✔
590
        prism = +self.cyl | -self.bottom | +self.top
12✔
591
        fillet = self._get_fillet()
12✔
592
        if fillet is not None:
12✔
593
            prism = prism | fillet
12✔
594
        return prism
12✔
595

596

597
class RectangularParallelepiped(CompositeSurface):
12✔
598
    """Rectangular parallelpiped composite surface
599

600
    A rectangular parallelpiped is composed of six planar surfaces. This class
601
    acts as a proper surface, meaning that unary `+` and `-` operators applied
602
    to it will produce a half-space. The negative side is defined to be the
603
    region inside of the rectangular parallelpiped.
604

605
    .. versionadded:: 0.12
606

607
    Parameters
608
    ----------
609
    xmin, xmax : float
610
        Minimum and maximum x coordinates of the parallelepiped
611
    ymin, ymax : float
612
        Minimum and maximum y coordinates of the parallelepiped
613
    zmin, zmax : float
614
        Minimum and maximum z coordinates of the parallelepiped
615
    **kwargs
616
        Keyword arguments passed to underlying plane classes
617

618
    Attributes
619
    ----------
620
    xmin, xmax : openmc.XPlane
621
        Sides of the parallelepiped
622
    ymin, ymax : openmc.YPlane
623
        Sides of the parallelepiped
624
    zmin, zmax : openmc.ZPlane
625
        Sides of the parallelepiped
626

627
    """
628
    _surface_names = ('xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax')
12✔
629

630
    def __init__(self, xmin, xmax, ymin, ymax, zmin, zmax, **kwargs):
12✔
631
        if xmin >= xmax:
12✔
UNCOV
632
            raise ValueError('xmin must be less than xmax')
×
633
        if ymin >= ymax:
12✔
UNCOV
634
            raise ValueError('ymin must be less than ymax')
×
635
        if zmin >= zmax:
12✔
UNCOV
636
            raise ValueError('zmin must be less than zmax')
×
637
        self.xmin = openmc.XPlane(x0=xmin, **kwargs)
12✔
638
        self.xmax = openmc.XPlane(x0=xmax, **kwargs)
12✔
639
        self.ymin = openmc.YPlane(y0=ymin, **kwargs)
12✔
640
        self.ymax = openmc.YPlane(y0=ymax, **kwargs)
12✔
641
        self.zmin = openmc.ZPlane(z0=zmin, **kwargs)
12✔
642
        self.zmax = openmc.ZPlane(z0=zmax, **kwargs)
12✔
643

644
    def __neg__(self):
12✔
645
        return -self.xmax & +self.xmin & -self.ymax & +self.ymin & -self.zmax & +self.zmin
12✔
646

647
    def __pos__(self):
12✔
648
        return +self.xmax | -self.xmin | +self.ymax | -self.ymin | +self.zmax | -self.zmin
12✔
649

650

651
class OrthogonalBox(CompositeSurface):
12✔
652
    """Arbitrarily oriented orthogonal box
653

654
    This composite surface is composed of four or six planar surfaces that form
655
    an arbitrarily oriented orthogonal box when combined.
656

657
    Parameters
658
    ----------
659
    v : iterable of float
660
        (x,y,z) coordinates of a corner of the box
661
    a1 : iterable of float
662
        Vector of first side starting from ``v``
663
    a2 : iterable of float
664
        Vector of second side starting from ``v``
665
    a3 : iterable of float, optional
666
        Vector of third side starting from ``v``. When not specified, it is
667
        assumed that the box will be infinite along the vector normal to the
668
        plane specified by ``a1`` and ``a2``.
669
    **kwargs
670
        Keyword arguments passed to underlying plane classes
671

672
    Attributes
673
    ----------
674
    ax1_min, ax1_max : openmc.Plane
675
        Planes representing minimum and maximum along first axis
676
    ax2_min, ax2_max : openmc.Plane
677
        Planes representing minimum and maximum along second axis
678
    ax3_min, ax3_max : openmc.Plane
679
        Planes representing minimum and maximum along third axis
680

681
    """
682
    _surface_names = ('ax1_min', 'ax1_max', 'ax2_min', 'ax2_max', 'ax3_min', 'ax3_max')
12✔
683

684
    def __init__(self, v, a1, a2, a3=None, **kwargs):
12✔
685
        v = np.array(v)
12✔
686
        a1 = np.array(a1)
12✔
687
        a2 = np.array(a2)
12✔
688
        if has_a3 := a3 is not None:
12✔
689
            a3 = np.array(a3)
12✔
690
        else:
691
            a3 = np.cross(a1, a2)  # normal to plane specified by a1 and a2
12✔
692

693
        # Generate corners of box
694
        p1 = v
12✔
695
        p2 = v + a1
12✔
696
        p3 = v + a2
12✔
697
        p4 = v + a3
12✔
698
        p5 = v + a1 + a2
12✔
699
        p6 = v + a2 + a3
12✔
700
        p7 = v + a1 + a3
12✔
701

702
        # Generate 6 planes of box
703
        self.ax1_min = openmc.Plane.from_points(p1, p3, p4, **kwargs)
12✔
704
        self.ax1_max = openmc.Plane.from_points(p2, p5, p7, **kwargs)
12✔
705
        self.ax2_min = openmc.Plane.from_points(p1, p4, p2, **kwargs)
12✔
706
        self.ax2_max = openmc.Plane.from_points(p3, p6, p5, **kwargs)
12✔
707
        if has_a3:
12✔
708
            self.ax3_min = openmc.Plane.from_points(p1, p2, p3, **kwargs)
12✔
709
            self.ax3_max = openmc.Plane.from_points(p4, p7, p6, **kwargs)
12✔
710

711
        # Make sure a point inside the box produces the correct senses. If not,
712
        # flip the plane coefficients so it does.
713
        mid_point = v + (a1 + a2 + a3)/2
12✔
714
        nums = (1, 2, 3) if has_a3 else (1, 2)
12✔
715
        for num in nums:
12✔
716
            min_surf = getattr(self, f'ax{num}_min')
12✔
717
            max_surf = getattr(self, f'ax{num}_max')
12✔
718
            if mid_point in -min_surf:
12✔
UNCOV
719
                min_surf.flip_normal()
×
720
            if mid_point in +max_surf:
12✔
UNCOV
721
                max_surf.flip_normal()
×
722

723
    def __neg__(self):
12✔
724
        region = (+self.ax1_min & -self.ax1_max &
12✔
725
                  +self.ax2_min & -self.ax2_max)
726
        if hasattr(self, 'ax3_min'):
12✔
727
            region &= (+self.ax3_min & -self.ax3_max)
12✔
728
        return region
12✔
729

730

731
class XConeOneSided(CompositeSurface):
12✔
732
    r"""One-sided cone parallel the x-axis
733

734
    A one-sided cone is composed of a normal cone surface and a "disambiguation"
735
    surface that eliminates the ambiguity as to which region of space is
736
    included. This class acts as a proper surface, meaning that unary `+` and
737
    `-` operators applied to it will produce a half-space. The negative side is
738
    defined to be the region inside of the cone.
739

740
    .. versionadded:: 0.12
741

742
    Parameters
743
    ----------
744
    x0 : float, optional
745
        x-coordinate of the apex in [cm].
746
    y0 : float, optional
747
        y-coordinate of the apex in [cm].
748
    z0 : float, optional
749
        z-coordinate of the apex in [cm].
750
    r2 : float, optional
751
        The square of the slope of the cone. It is defined as
752
        :math:`\left(\frac{r}{h}\right)^2` for a radius, :math:`r` and an axial
753
        distance :math:`h` from the apex. An easy way to define this quantity is
754
        to take the square of the radius of the cone (in cm) 1 cm from the apex.
755
    up : bool
756
        Whether to select the side of the cone that extends to infinity in the
757
        positive direction of the coordinate axis (the positive half-space of
758
        the ambiguity plane)
759
    **kwargs
760
        Keyword arguments passed to underlying plane classes
761

762
    Attributes
763
    ----------
764
    cone : openmc.XCone
765
        Regular two-sided cone
766
    plane : openmc.XPlane
767
        Disambiguation surface
768
    up : bool
769
        Whether to select the side of the cone that extends to infinity in the
770
        positive direction of the coordinate axis (the positive half-space of
771
        the ambiguity plane)
772

773
    """
774
    _surface_names = ('cone', 'plane')
12✔
775

776
    def __init__(self, x0=0., y0=0., z0=0., r2=1., up=True, **kwargs):
12✔
777
        check_greater_than('cone R^2', r2, 0.0)
12✔
778
        self.cone = openmc.XCone(x0, y0, z0, r2, **kwargs)
12✔
779
        self.plane = openmc.XPlane(x0)
12✔
780
        self.up = up
12✔
781

782
    def __neg__(self):
12✔
783
        return -self.cone & (+self.plane if self.up else -self.plane)
12✔
784

785

786
class YConeOneSided(CompositeSurface):
12✔
787
    r"""One-sided cone parallel the y-axis
788

789
    A one-sided cone is composed of a normal cone surface and a "disambiguation"
790
    surface that eliminates the ambiguity as to which region of space is
791
    included. This class acts as a proper surface, meaning that unary `+` and
792
    `-` operators applied to it will produce a half-space. The negative side is
793
    defined to be the region inside of the cone.
794

795
    .. versionadded:: 0.12
796

797
    Parameters
798
    ----------
799
    x0 : float, optional
800
        x-coordinate of the apex in [cm].
801
    y0 : float, optional
802
        y-coordinate of the apex in [cm].
803
    z0 : float, optional
804
        z-coordinate of the apex in [cm].
805
    r2 : float, optional
806
        The square of the slope of the cone. It is defined as
807
        :math:`\left(\frac{r}{h}\right)^2` for a radius, :math:`r` and an axial
808
        distance :math:`h` from the apex. An easy way to define this quantity is
809
        to take the square of the radius of the cone (in cm) 1 cm from the apex.
810
    up : bool
811
        Whether to select the side of the cone that extends to infinity in the
812
        positive direction of the coordinate axis (the positive half-space of
813
        the ambiguity plane)
814
    **kwargs
815
        Keyword arguments passed to underlying plane classes
816

817
    Attributes
818
    ----------
819
    cone : openmc.YCone
820
        Regular two-sided cone
821
    plane : openmc.YPlane
822
        Disambiguation surface
823
    up : bool
824
        Whether to select the side of the cone that extends to infinity in the
825
        positive direction of the coordinate axis (the positive half-space of
826
        the ambiguity plane)
827

828
    """
829
    _surface_names = ('cone', 'plane')
12✔
830

831
    def __init__(self, x0=0., y0=0., z0=0., r2=1., up=True, **kwargs):
12✔
832
        check_greater_than('cone R^2', r2, 0.0)
12✔
833
        self.cone = openmc.YCone(x0, y0, z0, r2, **kwargs)
12✔
834
        self.plane = openmc.YPlane(y0)
12✔
835
        self.up = up
12✔
836

837
    __neg__ = XConeOneSided.__neg__
12✔
838

839

840
class ZConeOneSided(CompositeSurface):
12✔
841
    r"""One-sided cone parallel the z-axis
842

843
    A one-sided cone is composed of a normal cone surface and a "disambiguation"
844
    surface that eliminates the ambiguity as to which region of space is
845
    included. This class acts as a proper surface, meaning that unary `+` and
846
    `-` operators applied to it will produce a half-space. The negative side is
847
    defined to be the region inside of the cone.
848

849
    .. versionadded:: 0.12
850

851
    Parameters
852
    ----------
853
    x0 : float, optional
854
        x-coordinate of the apex in [cm].
855
    y0 : float, optional
856
        y-coordinate of the apex in [cm].
857
    z0 : float, optional
858
        z-coordinate of the apex in [cm].
859
    r2 : float, optional
860
        The square of the slope of the cone. It is defined as
861
        :math:`\left(\frac{r}{h}\right)^2` for a radius, :math:`r` and an axial
862
        distance :math:`h` from the apex. An easy way to define this quantity is
863
        to take the square of the radius of the cone (in cm) 1 cm from the apex.
864
    up : bool
865
        Whether to select the side of the cone that extends to infinity in the
866
        positive direction of the coordinate axis (the positive half-space of
867
        the ambiguity plane)
868
    **kwargs
869
        Keyword arguments passed to underlying plane classes
870

871
    Attributes
872
    ----------
873
    cone : openmc.ZCone
874
        Regular two-sided cone
875
    plane : openmc.ZPlane
876
        Disambiguation surface
877
    up : bool
878
        Whether to select the side of the cone that extends to infinity in the
879
        positive direction of the coordinate axis (the positive half-space of
880
        the ambiguity plane)
881

882
    """
883
    _surface_names = ('cone', 'plane')
12✔
884

885
    def __init__(self, x0=0., y0=0., z0=0., r2=1., up=True, **kwargs):
12✔
886
        check_greater_than('cone R^2', r2, 0.0)
12✔
887
        self.cone = openmc.ZCone(x0, y0, z0, r2, **kwargs)
12✔
888
        self.plane = openmc.ZPlane(z0)
12✔
889
        self.up = up
12✔
890

891
    __neg__ = XConeOneSided.__neg__
12✔
892

893

894
class Polygon(CompositeSurface):
12✔
895
    """Polygon formed from a path of closed points.
896

897
    .. versionadded:: 0.13.3
898

899
    Parameters
900
    ----------
901
    points : np.ndarray
902
        An Nx2 array of points defining the vertices of the polygon.
903
    basis : {'rz', 'xy', 'yz', 'xz'}, optional
904
        2D basis set for the polygon. The polygon is two dimensional and has
905
        infinite extent in the third (unspecified) dimension. For example, the
906
        'xy' basis produces a polygon with infinite extent in the +/- z
907
        direction. For the 'rz' basis the phi extent is infinite, thus forming
908
        an axisymmetric surface.
909

910
    Attributes
911
    ----------
912
    points : np.ndarray
913
        An Nx2 array of points defining the vertices of the polygon.
914
    basis : {'rz', 'xy', 'yz', 'xz'}
915
        2D basis set for the polygon.
916
    regions : list of openmc.Region
917
        A list of :class:`openmc.Region` objects, one for each of the convex polygons
918
        formed during the decomposition of the input polygon.
919
    region : openmc.Union
920
        The union of all the regions comprising the polygon.
921
    """
922

923
    def __init__(self, points, basis='rz'):
12✔
924
        check_value('basis', basis, ('xy', 'yz', 'xz', 'rz'))
12✔
925
        self._basis = basis
12✔
926

927
        # Create a constrained triangulation of the validated points.
928
        # The constrained triangulation is set to the _tri attribute
929
        self._constrain_triangulation(self._validate_points(points))
12✔
930

931
        # Decompose the polygon into groups of simplices forming convex subsets
932
        # and get the sets of (surface, operator) pairs defining the polygon
933
        self._surfsets = self._decompose_polygon_into_convex_sets()
12✔
934

935
        # Set surface names as required by CompositeSurface protocol
936
        surfnames = []
12✔
937
        i = 0
12✔
938
        for surfset in self._surfsets:
12✔
939
            for surf, op, on_boundary in surfset:
12✔
940
                if on_boundary:
12✔
941
                    setattr(self, f'surface_{i}', surf)
12✔
942
                    surfnames.append(f'surface_{i}')
12✔
943
                    i += 1
12✔
944
        self._surfnames = tuple(surfnames)
12✔
945

946
        # Generate a list of regions whose union represents the polygon.
947
        regions = []
12✔
948
        for surfs_ops in self._surfsets:
12✔
949
            regions.append([op(surf) for surf, op, _ in surfs_ops])
12✔
950
        self._regions = [openmc.Intersection(regs) for regs in regions]
12✔
951

952
        # Create the union of all the convex subsets
953
        self._region = openmc.Union(self._regions)
12✔
954

955
    def __neg__(self):
12✔
956
        return self._region
12✔
957

958
    @property
12✔
959
    def _surface_names(self):
12✔
UNCOV
960
        return self._surfnames
×
961

962
    @CompositeSurface.boundary_type.setter
12✔
963
    def boundary_type(self, boundary_type):
12✔
UNCOV
964
        if boundary_type != 'transmission':
×
UNCOV
965
            warnings.warn("Setting boundary_type to a value other than "
×
966
                          "'transmission' on Polygon composite surfaces can "
967
                          "result in unintended behavior. Please use the "
968
                          "regions property of the Polygon to generate "
969
                          "individual openmc.Cell objects to avoid unwanted "
970
                          "behavior.")
UNCOV
971
        for name in self._surface_names:
×
UNCOV
972
            getattr(self, name).boundary_type = boundary_type
×
973

974
    @property
12✔
975
    def points(self):
12✔
976
        return self._tri.points
12✔
977

978
    @property
12✔
979
    def basis(self):
12✔
980
        return self._basis
12✔
981

982
    @property
12✔
983
    def _normals(self):
12✔
984
        """Generate the outward normal unit vectors for the polygon."""
985
        # Rotation matrix for 90 degree clockwise rotation (-90 degrees about z
986
        # axis for an 'xy' basis).
987
        rotation = np.array([[0., 1.], [-1., 0.]])
12✔
988
        # Get the unit vectors that point from one point in the polygon to the
989
        # next given that they are ordered counterclockwise and that the final
990
        # point is connected to the first point
991
        tangents = np.diff(self.points, axis=0, append=[self.points[0, :]])
12✔
992
        tangents /= np.linalg.norm(tangents, axis=-1, keepdims=True)
12✔
993
        # Rotate the tangent vectors clockwise by 90 degrees, which for a
994
        # counter-clockwise ordered polygon will produce the outward normal
995
        # vectors.
996
        return rotation.dot(tangents.T).T
12✔
997

998
    @property
12✔
999
    def _equations(self):
12✔
1000
        normals = self._normals
12✔
1001
        equations = np.empty((normals.shape[0], 3))
12✔
1002
        equations[:, :2] = normals
12✔
1003
        equations[:, 2] = -np.sum(normals*self.points, axis=-1)
12✔
1004
        return equations
12✔
1005

1006
    @property
12✔
1007
    def regions(self):
12✔
1008
        return self._regions
12✔
1009

1010
    @property
12✔
1011
    def region(self):
12✔
UNCOV
1012
        return self._region
×
1013

1014
    def _validate_points(self, points):
12✔
1015
        """Ensure the closed path defined by points does not intersect and is
1016
        oriented counter-clockwise.
1017

1018
        Parameters
1019
        ----------
1020
        points : np.ndarray (Nx2)
1021
            An Nx2 array of coordinate pairs describing the vertices.
1022

1023
        Returns
1024
        -------
1025
        ordered_points : the input points ordered counter-clockwise
1026
        """
1027
        points = np.asarray(points, dtype=float)
12✔
1028
        check_iterable_type('points', points, float, min_depth=2, max_depth=2)
12✔
1029
        check_length('points', points[0, :], 2, 2)
12✔
1030

1031
        # If the last point is the same as the first, remove it and make sure
1032
        # there are still at least 3 points for a valid polygon.
1033
        if np.allclose(points[0, :], points[-1, :]):
12✔
1034
            points = points[:-1, :]
12✔
1035
        check_length('points', points, 3)
12✔
1036

1037
        if len(points) != len(np.unique(points, axis=0)):
12✔
1038
            raise ValueError('Duplicate points were detected in the Polygon input')
12✔
1039

1040
        # Order the points counter-clockwise (necessary for offset method)
1041
        # Calculates twice the signed area of the polygon using the "Shoelace
1042
        # Formula" https://en.wikipedia.org/wiki/Shoelace_formula
1043
        # If signed area is positive the curve is oriented counter-clockwise.
1044
        # If the signed area is negative the curve is oriented clockwise.
1045
        xpts, ypts = points.T
12✔
1046
        if np.sum(ypts*(np.roll(xpts, 1) - np.roll(xpts, -1))) < 0:
12✔
1047
            points = points[::-1, :]
12✔
1048

1049
        # Check if polygon is self-intersecting by comparing edges pairwise
1050
        n = len(points)
12✔
1051
        for i in range(n):
12✔
1052
            p0 = np.append(points[i, :], 0)
12✔
1053
            p1 = np.append(points[(i + 1) % n, :], 0)
12✔
1054
            for j in range(i + 1, n):
12✔
1055
                p2 = np.append(points[j, :], 0)
12✔
1056
                p3 = np.append(points[(j + 1) % n, :], 0)
12✔
1057
                # Compute orientation of p0 wrt p2->p3 line segment
1058
                cp0 = np.cross(p3-p0, p2-p0)[-1]
12✔
1059
                # Compute orientation of p1 wrt p2->p3 line segment
1060
                cp1 = np.cross(p3-p1, p2-p1)[-1]
12✔
1061
                # Compute orientation of p2 wrt p0->p1 line segment
1062
                cp2 = np.cross(p1-p2, p0-p2)[-1]
12✔
1063
                # Compute orientation of p3 wrt p0->p1 line segment
1064
                cp3 = np.cross(p1-p3, p0-p3)[-1]
12✔
1065

1066
                # Group cross products in an array and find out how many are 0
1067
                cross_products = np.array([[cp0, cp1], [cp2, cp3]])
12✔
1068
                cps_near_zero = np.isclose(cross_products, 0).astype(int)
12✔
1069
                num_zeros = np.sum(cps_near_zero)
12✔
1070

1071
                # Topologies of 2 finite line segments categorized by the number
1072
                # of zero-valued cross products:
1073
                #
1074
                # 0: No 3 points lie on the same line
1075
                # 1: 1 point lies on the same line defined by the other line
1076
                # segment, but is not coincident with either of the points
1077
                # 2: 2 points are coincident, but the line segments are not
1078
                # collinear which guarantees no intersection
1079
                # 3: not possible, except maybe floating point issues?
1080
                # 4: Both line segments are collinear, simply need to check if
1081
                # they overlap or not
1082
                # adapted from algorithm linked below and modified to only
1083
                # consider intersections on the interior of line segments as
1084
                # proper intersections: i.e. segments sharing end points do not
1085
                # count as intersections.
1086
                # https://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/
1087

1088
                if num_zeros == 0:
12✔
1089
                    # If the orientations of p0 and p1 have opposite signs
1090
                    # and the orientations of p2 and p3 have opposite signs
1091
                    # then there is an intersection.
1092
                    if all(np.prod(cross_products, axis=-1) < 0):
12✔
1093
                        raise ValueError('Polygon cannot be self-intersecting')
12✔
1094
                    continue
12✔
1095

1096
                elif num_zeros == 1:
12✔
1097
                    # determine which line segment has 2 out of the 3 collinear
1098
                    # points
1099
                    idx = np.argwhere(np.sum(cps_near_zero, axis=-1) == 0)
12✔
1100
                    if np.prod(cross_products[idx, :]) < 0:
12✔
1101
                        raise ValueError('Polygon cannot be self-intersecting')
12✔
1102
                    continue
12✔
1103

1104
                elif num_zeros == 2:
12✔
1105
                    continue
12✔
1106

1107
                elif num_zeros == 3:
12✔
UNCOV
1108
                    warnings.warn('Unclear if Polygon is self-intersecting')
×
UNCOV
1109
                    continue
×
1110

1111
                else:
1112
                    # All 4 cross products are zero
1113
                    # Determine number of unique points, x span and y span for
1114
                    # both line segments
1115
                    xmin1, xmax1 = min(p0[0], p1[0]), max(p0[0], p1[0])
12✔
1116
                    ymin1, ymax1 = min(p0[1], p1[1]), max(p0[1], p1[1])
12✔
1117
                    xmin2, xmax2 = min(p2[0], p3[0]), max(p2[0], p3[0])
12✔
1118
                    ymin2, ymax2 = min(p2[1], p3[1]), max(p2[1], p3[1])
12✔
1119
                    xlap = xmin1 < xmax2 and xmin2 < xmax1
12✔
1120
                    ylap = ymin1 < ymax2 and ymin2 < ymax1
12✔
1121
                    if xlap or ylap:
12✔
UNCOV
1122
                        raise ValueError('Polygon cannot be self-intersecting')
×
1123
                    continue
12✔
1124

1125
        return points
12✔
1126

1127
    def _constrain_triangulation(self, points, depth=0):
12✔
1128
        """Generate a constrained triangulation by ensuring all edges of the
1129
        Polygon are contained within the simplices.
1130

1131
        Parameters
1132
        ----------
1133
        points : np.ndarray (Nx2)
1134
            An Nx2 array of coordinate pairs describing the vertices. These
1135
            points represent a planar straight line graph.
1136

1137
        Returns
1138
        -------
1139
        None
1140
        """
1141
        # Only attempt the triangulation up to 5 times.
1142
        if depth > 4:
12✔
UNCOV
1143
            raise RuntimeError('Could not create a valid triangulation after 5'
×
1144
                               ' attempts')
1145

1146
        tri = Delaunay(points, qhull_options='QJ')
12✔
1147
        # Loop through the boundary edges of the polygon. If an edge is not
1148
        # included in the triangulation, break it into two line segments.
1149
        n = len(points)
12✔
1150
        new_pts = []
12✔
1151
        for i, j in zip(range(n), range(1, n + 1)):
12✔
1152
            # If both vertices of any edge are not found in any simplex, insert
1153
            # a new point between them.
1154
            if not any([i in s and j % n in s for s in tri.simplices]):
12✔
1155
                newpt = (points[i, :] + points[j % n, :]) / 2
12✔
1156
                new_pts.append((j, newpt))
12✔
1157

1158
        # If all the edges are included in the triangulation set it, otherwise
1159
        # try again with additional points inserted on offending edges.
1160
        if not new_pts:
12✔
1161
            self._tri = tri
12✔
1162
        else:
1163
            for i, pt in new_pts[::-1]:
12✔
1164
                points = np.insert(points, i, pt, axis=0)
12✔
1165
            self._constrain_triangulation(points, depth=depth + 1)
12✔
1166

1167
    def _group_simplices(self, neighbor_map, group=None):
12✔
1168
        """Generate a convex grouping of simplices.
1169

1170
        Parameters
1171
        ----------
1172
        neighbor_map : dict
1173
            A map whose keys are simplex indices for simplices inside the polygon
1174
            and whose values are a list of simplex indices that neighbor this
1175
            simplex and are also inside the polygon.
1176
        group : list
1177
            A list of simplex indices that comprise the current convex group.
1178

1179
        Returns
1180
        -------
1181
        group : list
1182
            The list of simplex indices that comprise the complete convex group.
1183
        """
1184
        # If neighbor_map is empty there's nothing left to do
1185
        if not neighbor_map:
12✔
UNCOV
1186
            return group
×
1187
        # If group is empty, grab the next simplex in the dictionary and recurse
1188
        if group is None:
12✔
1189
            # Start with smallest neighbor lists
1190
            sidx = sorted(neighbor_map.items(), key=lambda item: len(item[1]))[0][0]
12✔
1191
            return self._group_simplices(neighbor_map, group=[sidx])
12✔
1192
        # Otherwise use the last simplex in the group
1193
        else:
1194
            sidx = group[-1]
12✔
1195
            # Remove current simplex from dictionary since it is in a group
1196
            neighbors = neighbor_map.pop(sidx, [])
12✔
1197
            # For each neighbor check if it is part of the same convex
1198
            # hull as the rest of the group. If yes, recurse. If no, continue on.
1199
            for n in neighbors:
12✔
1200
                if n in group or neighbor_map.get(n) is None:
12✔
1201
                    continue
12✔
1202
                test_group = group + [n]
12✔
1203
                test_point_idx = np.unique(self._tri.simplices[test_group, :])
12✔
1204
                test_points = self.points[test_point_idx]
12✔
1205
                test_hull = ConvexHull(test_points, qhull_options='Qc')
12✔
1206
                pts_on_hull = len(test_hull.vertices) + len(test_hull.coplanar)
12✔
1207
                # If test_points are convex (including coplanar) keep adding to
1208
                # this group
1209
                if len(test_points) == pts_on_hull:
12✔
1210
                    group = self._group_simplices(neighbor_map, group=test_group)
12✔
1211
            return group
12✔
1212

1213
    def _get_convex_hull_surfs(self, qhull):
12✔
1214
        """Generate a list of surfaces given by a set of linear equations
1215

1216
        Parameters
1217
        ----------
1218
        qhull : scipy.spatial.ConvexHull
1219
            A ConvexHull object representing the sub-region of the polygon.
1220

1221
        Returns
1222
        -------
1223
        surfs_ops : list of (surface, operator) tuples
1224

1225
        """
1226
        basis = self.basis
12✔
1227
        boundary_eqns = self._equations
12✔
1228
        # Collect surface/operator pairs such that the intersection of the
1229
        # regions defined by these pairs is the inside of the polygon.
1230
        surfs_ops = []
12✔
1231
        # hull facet equation: dx*x + dy*y + c = 0
1232
        for dx, dy, c in qhull.equations:
12✔
1233
            # check if this facet is on the boundary of the polygon
1234
            facet_eq = np.array([dx, dy, c])
12✔
1235
            on_boundary = any([np.allclose(facet_eq, eq) for eq in boundary_eqns])
12✔
1236
            # Check if the facet is horizontal
1237
            if isclose(dx, 0, abs_tol=1e-8):
12✔
1238
                if basis in ('xz', 'yz', 'rz'):
12✔
1239
                    surf = openmc.ZPlane(z0=-c/dy)
12✔
1240
                else:
1241
                    surf = openmc.YPlane(y0=-c/dy)
12✔
1242
                # if (0, 1).(dx, dy) < 0 we want positive halfspace instead
1243
                op = operator.pos if dy < 0 else operator.neg
12✔
1244
            # Check if the facet is vertical
1245
            elif isclose(dy, 0, abs_tol=1e-8):
12✔
1246
                if basis in ('xy', 'xz'):
12✔
1247
                    surf = openmc.XPlane(x0=-c/dx)
12✔
UNCOV
1248
                elif basis == 'yz':
×
UNCOV
1249
                    surf = openmc.YPlane(y0=-c/dx)
×
1250
                else:
1251
                    surf = openmc.ZCylinder(r=-c/dx)
×
1252
                # if (1, 0).(dx, dy) < 0 we want positive halfspace instead
1253
                op = operator.pos if dx < 0 else operator.neg
12✔
1254
            # Otherwise the facet is at an angle
1255
            else:
1256
                op = operator.neg
12✔
1257
                if basis == 'xy':
12✔
1258
                    surf = openmc.Plane(a=dx, b=dy, c=0.0, d=-c)
12✔
1259
                elif basis == 'yz':
12✔
1260
                    surf = openmc.Plane(a=0.0, b=dx, c=dy, d=-c)
12✔
1261
                elif basis == 'xz':
12✔
1262
                    surf = openmc.Plane(a=dx, b=0.0, c=dy, d=-c)
12✔
1263
                else:
1264
                    y0 = -c/dy
12✔
1265
                    r2 = dy**2 / dx**2
12✔
1266
                    # Check if the *slope* of the facet is positive. If dy/dx < 0
1267
                    # then we want up to be True for the one-sided cones.
1268
                    up = dy / dx < 0
12✔
1269
                    surf = openmc.model.ZConeOneSided(z0=y0, r2=r2, up=up)
12✔
1270
                    # if (1, -1).(dx, dy) < 0 for up cones we want positive halfspace
1271
                    # if (1, 1).(dx, dy) < 0 for down cones we want positive halfspace
1272
                    # otherwise we keep the negative halfspace operator
1273
                    if (up and dx - dy < 0) or (not up and dx + dy < 0):
12✔
1274
                        op = operator.pos
12✔
1275

1276
            surfs_ops.append((surf, op, on_boundary))
12✔
1277

1278
        return surfs_ops
12✔
1279

1280
    def _decompose_polygon_into_convex_sets(self):
12✔
1281
        """Decompose the Polygon into a set of convex polygons.
1282

1283
        Returns
1284
        -------
1285
        surfsets : a list of lists of surface, operator pairs
1286
        """
1287
        from matplotlib.path import Path
12✔
1288

1289
        # Get centroids of all the simplices and determine if they are inside
1290
        # the polygon defined by input vertices or not.
1291
        centroids = np.mean(self.points[self._tri.simplices], axis=1)
12✔
1292
        in_polygon = Path(self.points).contains_points(centroids)
12✔
1293
        self._in_polygon = in_polygon
12✔
1294

1295
        # Build a map with keys of simplex indices inside the polygon whose
1296
        # values are lists of that simplex's neighbors also inside the
1297
        # polygon
1298
        neighbor_map = {}
12✔
1299
        for i, nlist in enumerate(self._tri.neighbors):
12✔
1300
            if not in_polygon[i]:
12✔
1301
                continue
12✔
1302
            neighbor_map[i] = [n for n in nlist if in_polygon[n] and n >=0]
12✔
1303

1304
        # Get the groups of simplices forming convex polygons whose union
1305
        # comprises the full input polygon. While there are still simplices
1306
        # left in the neighbor map, group them together into convex sets.
1307
        groups = []
12✔
1308
        while neighbor_map:
12✔
1309
            groups.append(self._group_simplices(neighbor_map))
12✔
1310
        self._groups = groups
12✔
1311

1312
        # Generate lists of (surface, operator) pairs for each convex
1313
        # sub-region.
1314
        surfsets = []
12✔
1315
        for group in groups:
12✔
1316
            # Find all the unique points in the convex group of simplices,
1317
            # generate the convex hull and find the resulting surfaces and
1318
            # unary operators that represent this convex subset of the polygon.
1319
            idx = np.unique(self._tri.simplices[group, :])
12✔
1320
            qhull = ConvexHull(self.points[idx, :])
12✔
1321
            surf_ops = self._get_convex_hull_surfs(qhull)
12✔
1322
            surfsets.append(surf_ops)
12✔
1323
        return surfsets
12✔
1324

1325
    def offset(self, distance: float | Sequence[float] | np.ndarray) -> Polygon:
12✔
1326
        """Offset this polygon by a set distance
1327

1328
        Parameters
1329
        ----------
1330
        distance : float or sequence of float or np.ndarray
1331
            The distance to offset the polygon by. Positive is outward
1332
            (expanding) and negative is inward (shrinking). If a float is
1333
            provided, the same offset is applied to all vertices. If a list or
1334
            tuple is provided, each vertex gets a different offset. If an
1335
            iterable or numpy array is provided, each vertex gets a different
1336
            offset.
1337

1338
        Returns
1339
        -------
1340
        offset_polygon : openmc.model.Polygon
1341
        """
1342

1343
        if isinstance(distance, float):
12✔
1344
            distance = np.full(len(self.points), distance)
12✔
1345
        elif isinstance(distance, Sequence):
12✔
1346
            distance = np.array(distance)
12✔
1347
        elif not isinstance(distance, np.ndarray):
12✔
UNCOV
1348
            raise TypeError("Distance must be a float or sequence of float.")
×
1349

1350
        if len(distance) != len(self.points):
12✔
1351
            raise ValueError(
12✔
1352
                f"Length of distance {len(distance)} array must "
1353
                f"match number of polygon points {len(self.points)}"
1354
            )
1355

1356
        normals = np.insert(self._normals, 0, self._normals[-1, :], axis=0)
12✔
1357
        cos2theta = np.sum(normals[1:, :]*normals[:-1, :], axis=-1, keepdims=True)
12✔
1358
        costheta = np.cos(np.arccos(cos2theta) / 2)
12✔
1359
        nvec = (normals[1:, :] + normals[:-1, :])
12✔
1360
        unit_nvec = nvec / np.linalg.norm(nvec, axis=-1, keepdims=True)
12✔
1361
        disp_vec = distance[:, np.newaxis] / costheta * unit_nvec
12✔
1362

1363
        return type(self)(self.points + disp_vec, basis=self.basis)
12✔
1364

1365

1366
class CruciformPrism(CompositeSurface):
12✔
1367
    """Generalized cruciform prism
1368

1369
    This surface represents a prism parallel to an axis formed by planes at
1370
    multiple distances from the center. Equivalent to the 'gcross' derived
1371
    surface in Serpent.
1372

1373
    .. versionadded:: 0.14.0
1374

1375
    Parameters
1376
    ----------
1377
    distances : iterable of float
1378
        A monotonically increasing (or decreasing) iterable of distances in [cm]
1379
        that form the planes of the generalized cruciform.
1380
    center : iterable of float
1381
        The center of the prism in the two non-parallel axes (e.g., (x, y) when
1382
        axis is 'z') in [cm]
1383
    axis : {'x', 'y', 'z'}
1384
        Axis to which the prism is parallel
1385
    **kwargs
1386
        Keyword arguments passed to underlying plane classes
1387

1388
    """
1389

1390
    def __init__(self, distances, center=(0., 0.), axis='z', **kwargs):
12✔
1391
        x0, y0 = center
12✔
1392
        self.distances = distances
12✔
1393

1394
        if axis == 'x':
12✔
1395
            cls_horizontal = openmc.YPlane
12✔
1396
            cls_vertical = openmc.ZPlane
12✔
1397
        elif axis == 'y':
12✔
1398
            cls_horizontal = openmc.XPlane
12✔
1399
            cls_vertical = openmc.ZPlane
12✔
1400
        elif axis == 'z':
12✔
1401
            cls_horizontal = openmc.XPlane
12✔
1402
            cls_vertical = openmc.YPlane
12✔
1403
        else:
UNCOV
1404
            raise ValueError("axis must be 'x', 'y', or 'z'")
×
1405

1406
        # Create each planar surface
1407
        surfnames = []
12✔
1408
        for i, d in enumerate(distances):
12✔
1409
            setattr(self, f'hmin{i}', cls_horizontal(x0 - d, **kwargs))
12✔
1410
            setattr(self, f'hmax{i}', cls_horizontal(x0 + d, **kwargs))
12✔
1411
            setattr(self, f'vmin{i}', cls_vertical(y0 - d, **kwargs))
12✔
1412
            setattr(self, f'vmax{i}', cls_vertical(y0 + d, **kwargs))
12✔
1413
            surfnames.extend([f'hmin{i}', f'hmax{i}', f'vmin{i}', f'vmax{i}'])
12✔
1414

1415
        # Set _surfnames to satisfy CompositeSurface protocol
1416
        self._surfnames = tuple(surfnames)
12✔
1417

1418
    @property
12✔
1419
    def _surface_names(self):
12✔
1420
        return self._surfnames
12✔
1421

1422
    @property
12✔
1423
    def distances(self):
12✔
1424
        return self._distances
12✔
1425

1426
    @distances.setter
12✔
1427
    def distances(self, values):
12✔
1428
        values = np.array(values, dtype=float)
12✔
1429
        # check for positive values
1430
        if not (values > 0).all():
12✔
UNCOV
1431
            raise ValueError("distances must be positive")
×
1432
        # Check for monotonicity
1433
        if (values[1:] > values[:-1]).all() or (values[1:] < values[:-1]).all():
12✔
1434
            self._distances = values
12✔
1435
        else:
1436
            raise ValueError("distances must be monotonic")
12✔
1437

1438
    def __neg__(self):
12✔
1439
        n = len(self.distances)
12✔
1440
        regions = []
12✔
1441
        for i in range(n):
12✔
1442
            regions.append(
12✔
1443
                +getattr(self, f'hmin{i}') &
1444
                -getattr(self, f'hmax{i}') &
1445
                +getattr(self, f'vmin{n-1-i}') &
1446
                -getattr(self, f'vmax{n-1-i}')
1447
            )
1448
        return openmc.Union(regions)
12✔
1449

1450

1451
# Define function to create a plane on given axis
1452
def _plane(axis, name, value, boundary_type='transmission', albedo=1.0):
12✔
1453
        cls = getattr(openmc, f'{axis.upper()}Plane')
12✔
1454
        return cls(value, name=f'{name} {axis}',
12✔
1455
                   boundary_type=boundary_type, albedo=albedo)
1456

1457

1458
class RectangularPrism(CompositeSurface):
12✔
1459
    """Infinite rectangular prism bounded by four planar surfaces.
1460

1461
    .. versionadded:: 0.14.0
1462

1463
    Parameters
1464
    ----------
1465
    width : float
1466
        Prism width in units of [cm]. The width is aligned with the x, x, or z
1467
        axes for prisms parallel to the x, y, or z axis, respectively.
1468
    height : float
1469
        Prism height in units of [cm]. The height is aligned with the x, y, or z
1470
        axes for prisms parallel to the x, y, or z axis, respectively.
1471
    axis : {'x', 'y', 'z'}
1472
        Axis with which the infinite length of the prism should be aligned.
1473
    origin : Iterable of two floats
1474
        Origin of the prism. The two floats correspond to (y,z), (x,z) or (x,y)
1475
        for prisms parallel to the x, y or z axis, respectively.
1476
    boundary_type : {'transmission, 'vacuum', 'reflective', 'periodic', 'white'}
1477
        Boundary condition that defines the behavior for particles hitting the
1478
        surfaces comprising the rectangular prism.
1479
    albedo : float, optional
1480
        Albedo of the prism's surfaces as a ratio of particle weight after
1481
        interaction with the surface to the initial weight. Values must be
1482
        positive. Only applicable if the boundary type is 'reflective',
1483
        'periodic', or 'white'.
1484
    corner_radius : float
1485
        Prism corner radius in units of [cm].
1486

1487
    """
1488
    _surface_names = ('min_x1', 'max_x1', 'min_x2', 'max_x2')
12✔
1489

1490
    def __init__(
12✔
1491
            self,
1492
            width: float,
1493
            height: float,
1494
            axis: str = 'z',
1495
            origin: Sequence[float] = (0., 0.),
1496
            boundary_type: str = 'transmission',
1497
            albedo: float = 1.,
1498
            corner_radius: float = 0.
1499
        ):
1500
        check_type('width', width, Real)
12✔
1501
        check_type('height', height, Real)
12✔
1502
        check_type('albedo', albedo, Real)
12✔
1503
        check_type('corner_radius', corner_radius, Real)
12✔
1504
        check_value('axis', axis, ('x', 'y', 'z'))
12✔
1505
        check_type('origin', origin, Iterable, Real)
12✔
1506

1507
        if axis == 'x':
12✔
UNCOV
1508
            x1, x2 = 'y', 'z'
×
1509
        elif axis == 'y':
12✔
1510
            x1, x2 = 'x', 'z'
×
1511
        else:
1512
            x1, x2 = 'x', 'y'
12✔
1513

1514
        # Get cylinder class corresponding to given axis
1515
        cyl = getattr(openmc, f'{axis.upper()}Cylinder')
12✔
1516

1517
        # Create container for boundary arguments
1518
        bc_args = {'boundary_type': boundary_type, 'albedo': albedo}
12✔
1519

1520
        # Create rectangular region
1521
        self.min_x1 = _plane(x1, 'minimum', -width/2 + origin[0], **bc_args)
12✔
1522
        self.max_x1 = _plane(x1, 'maximum', width/2 + origin[0], **bc_args)
12✔
1523
        self.min_x2 = _plane(x2, 'minimum', -height/2 + origin[1], **bc_args)
12✔
1524
        self.max_x2 = _plane(x2, 'maximum', height/2 + origin[1], **bc_args)
12✔
1525
        if boundary_type == 'periodic':
12✔
1526
            self.min_x1.periodic_surface = self.max_x1
×
1527
            self.min_x2.periodic_surface = self.max_x2
×
1528

1529
        # Handle rounded corners if given
1530
        if corner_radius > 0.:
12✔
1531
            if boundary_type == 'periodic':
×
UNCOV
1532
                raise ValueError('Periodic boundary conditions not permitted when '
×
1533
                                'rounded corners are used.')
1534

1535
            args = {'r': corner_radius, 'boundary_type': boundary_type, 'albedo': albedo}
×
1536

1537
            args[x1 + '0'] = origin[0] - width/2 + corner_radius
×
UNCOV
1538
            args[x2 + '0'] = origin[1] - height/2 + corner_radius
×
1539
            self.x1_min_x2_min = cyl(name=f'{x1} min {x2} min', **args)
×
1540

1541
            args[x1 + '0'] = origin[0] - width/2 + corner_radius
×
UNCOV
1542
            args[x2 + '0'] = origin[1] + height/2 - corner_radius
×
1543
            self.x1_min_x2_max = cyl(name=f'{x1} min {x2} max', **args)
×
1544

1545
            args[x1 + '0'] = origin[0] + width/2 - corner_radius
×
UNCOV
1546
            args[x2 + '0'] = origin[1] - height/2 + corner_radius
×
UNCOV
1547
            self.x1_max_x2_min = cyl(name=f'{x1} max {x2} min', **args)
×
1548

UNCOV
1549
            args[x1 + '0'] = origin[0] + width/2 - corner_radius
×
UNCOV
1550
            args[x2 + '0'] = origin[1] + height/2 - corner_radius
×
UNCOV
1551
            self.x1_max_x2_max = cyl(name=f'{x1} max {x2} max', **args)
×
1552

UNCOV
1553
            self.x1_min = _plane(x1, 'min', -width/2 + origin[0] + corner_radius,
×
1554
                                 **bc_args)
1555
            self.x1_max = _plane(x1, 'max', width/2 + origin[0] - corner_radius,
×
1556
                                 **bc_args)
UNCOV
1557
            self.x2_min = _plane(x2, 'min', -height/2 + origin[1] + corner_radius,
×
1558
                                 **bc_args)
UNCOV
1559
            self.x2_max = _plane(x2, 'max', height/2 + origin[1] - corner_radius,
×
1560
                                 **bc_args)
1561
            self._surface_names += (
×
1562
                'x1_min_x2_min', 'x1_min_x2_max', 'x1_max_x2_min',
1563
                'x1_max_x2_max', 'x1_min', 'x1_max', 'x2_min', 'x2_max'
1564
            )
1565

1566
    def __neg__(self):
12✔
1567
        prism = +self.min_x1 & -self.max_x1 & +self.min_x2 & -self.max_x2
12✔
1568

1569
        # Cut out corners if a corner radius was given
1570
        if hasattr(self, 'x1_min'):
12✔
UNCOV
1571
            corners = (
×
1572
                (+self.x1_min_x2_min & -self.x1_min & -self.x2_min) |
1573
                (+self.x1_min_x2_max & -self.x1_min & +self.x2_max) |
1574
                (+self.x1_max_x2_min & +self.x1_max & -self.x2_min) |
1575
                (+self.x1_max_x2_max & +self.x1_max & +self.x2_max)
1576
            )
UNCOV
1577
            prism &= ~corners
×
1578

1579
        return prism
12✔
1580

1581

1582
class HexagonalPrism(CompositeSurface):
12✔
1583
    """Hexagonal prism comoposed of six planar surfaces
1584

1585
    .. versionadded:: 0.14.0
1586

1587
    Parameters
1588
    ----------
1589
    edge_length : float
1590
        Length of a side of the hexagon in [cm]
1591
    orientation : {'x', 'y'}
1592
        An 'x' orientation means that two sides of the hexagon are parallel to
1593
        the x-axis and a 'y' orientation means that two sides of the hexagon are
1594
        parallel to the y-axis.
1595
    origin : Iterable of two floats
1596
        Origin of the prism.
1597
    boundary_type : {'transmission, 'vacuum', 'reflective', 'periodic', 'white'}
1598
        Boundary condition that defines the behavior for particles hitting the
1599
        surfaces comprising the hexagonal prism.
1600
    albedo : float, optional
1601
        Albedo of the prism's surfaces as a ratio of particle weight after
1602
        interaction with the surface to the initial weight. Values must be
1603
        positive. Only applicable if the boundary type is 'reflective',
1604
        'periodic', or 'white'.
1605
    corner_radius : float
1606
        Prism corner radius in units of [cm].
1607

1608
    """
1609
    _surface_names = ('plane_max', 'plane_min', 'upper_right', 'upper_left',
12✔
1610
                      'lower_right', 'lower_left')
1611

1612
    def __init__(
12✔
1613
            self,
1614
            edge_length: float = 1.,
1615
            orientation: str = 'y',
1616
            origin: Sequence[float] = (0., 0.),
1617
            boundary_type: str = 'transmission',
1618
            albedo: float = 1.,
1619
            corner_radius: float = 0.
1620
    ):
1621
        check_type('edge_length', edge_length, Real)
12✔
1622
        check_type('albedo', albedo, Real)
12✔
1623
        check_type('corner_radius', corner_radius, Real)
12✔
1624
        check_value('orientation', orientation, ('x', 'y'))
12✔
1625
        check_type('origin', origin, Iterable, Real)
12✔
1626

1627
        l = edge_length
12✔
1628
        x, y = origin
12✔
1629

1630
        # Create container for boundary arguments
1631
        bc_args = {'boundary_type': boundary_type, 'albedo': albedo}
12✔
1632

1633
        if orientation == 'y':
12✔
1634
            # Left and right planes
1635
            self.plane_max = openmc.XPlane(x + sqrt(3.)/2*l, **bc_args)
12✔
1636
            self.plane_min = openmc.XPlane(x - sqrt(3.)/2*l, **bc_args)
12✔
1637
            c = sqrt(3.)/3.
12✔
1638

1639
            # y = -x/sqrt(3) + a
1640
            self.upper_right = openmc.Plane(a=c, b=1., d=l+x*c+y, **bc_args)
12✔
1641

1642
            # y = x/sqrt(3) + a
1643
            self.upper_left = openmc.Plane(a=-c, b=1., d=l-x*c+y, **bc_args)
12✔
1644

1645
            # y = x/sqrt(3) - a
1646
            self.lower_right = openmc.Plane(a=-c, b=1., d=-l-x*c+y, **bc_args)
12✔
1647

1648
            # y = -x/sqrt(3) - a
1649
            self.lower_left = openmc.Plane(a=c, b=1., d=-l+x*c+y, **bc_args)
12✔
1650

1651
        elif orientation == 'x':
12✔
1652
            self.plane_max = openmc.YPlane(y + sqrt(3.)/2*l, **bc_args)
12✔
1653
            self.plane_min = openmc.YPlane(y - sqrt(3.)/2*l, **bc_args)
12✔
1654
            c = sqrt(3.)
12✔
1655

1656
            # Upper-right surface: y = -sqrt(3)*(x - a)
1657
            self.upper_right = openmc.Plane(a=c, b=1., d=c*l+x*c+y, **bc_args)
12✔
1658

1659
            # Lower-right surface: y = sqrt(3)*(x + a)
1660
            self.lower_right = openmc.Plane(a=-c, b=1., d=-c*l-x*c+y, **bc_args)
12✔
1661

1662
            # Lower-left surface: y = -sqrt(3)*(x + a)
1663
            self.lower_left = openmc.Plane(a=c, b=1., d=-c*l+x*c+y, **bc_args)
12✔
1664

1665
            # Upper-left surface: y = sqrt(3)*(x + a)
1666
            self.upper_left = openmc.Plane(a=-c, b=1., d=c*l-x*c+y, **bc_args)
12✔
1667

1668
        # Handle periodic boundary conditions
1669
        if boundary_type == 'periodic':
12✔
1670
            self.plane_min.periodic_surface = self.plane_max
12✔
1671
            self.upper_right.periodic_surface = self.lower_left
12✔
1672
            self.lower_right.periodic_surface = self.upper_left
12✔
1673

1674
        # Handle rounded corners if given
1675
        if corner_radius > 0.:
12✔
1676
            if boundary_type == 'periodic':
12✔
1677
                raise ValueError('Periodic boundary conditions not permitted '
×
1678
                                 'when rounded corners are used.')
1679

1680
            c = sqrt(3.)/2
12✔
1681
            t = l - corner_radius/c
12✔
1682

1683
            # Cylinder with corner radius and boundary type pre-applied
1684
            cyl1 = partial(openmc.ZCylinder, r=corner_radius, **bc_args)
12✔
1685
            cyl2 = partial(openmc.ZCylinder, r=corner_radius/(2*c), **bc_args)
12✔
1686

1687
            if orientation == 'x':
12✔
UNCOV
1688
                self.x_min_y_min_in = cyl1(name='x min y min in', x0=x-t/2, y0=y-c*t)
×
UNCOV
1689
                self.x_min_y_max_in = cyl1(name='x min y max in', x0=x+t/2, y0=y-c*t)
×
UNCOV
1690
                self.x_max_y_min_in = cyl1(name='x max y min in', x0=x-t/2, y0=y+c*t)
×
UNCOV
1691
                self.x_max_y_max_in = cyl1(name='x max y max in', x0=x+t/2, y0=y+c*t)
×
UNCOV
1692
                self.min_in = cyl1(name='x min in', x0=x-t, y0=y)
×
UNCOV
1693
                self.max_in = cyl1(name='x max in', x0=x+t, y0=y)
×
1694

UNCOV
1695
                self.x_min_y_min_out = cyl2(name='x min y min out', x0=x-l/2, y0=y-c*l)
×
UNCOV
1696
                self.x_min_y_max_out = cyl2(name='x min y max out', x0=x+l/2, y0=y-c*l)
×
UNCOV
1697
                self.x_max_y_min_out = cyl2(name='x max y min out', x0=x-l/2, y0=y+c*l)
×
UNCOV
1698
                self.x_max_y_max_out = cyl2(name='x max y max out', x0=x+l/2, y0=y+c*l)
×
UNCOV
1699
                self.min_out = cyl2(name='x min out', x0=x-l, y0=y)
×
UNCOV
1700
                self.max_out = cyl2(name='x max out', x0=x+l, y0=y)
×
1701

1702
            elif orientation == 'y':
12✔
1703
                self.x_min_y_min_in = cyl1(name='x min y min in', x0=x-c*t, y0=y-t/2)
12✔
1704
                self.x_min_y_max_in = cyl1(name='x min y max in', x0=x-c*t, y0=y+t/2)
12✔
1705
                self.x_max_y_min_in = cyl1(name='x max y min in', x0=x+c*t, y0=y-t/2)
12✔
1706
                self.x_max_y_max_in = cyl1(name='x max y max in', x0=x+c*t, y0=y+t/2)
12✔
1707
                self.min_in = cyl1(name='y min in', x0=x, y0=y-t)
12✔
1708
                self.max_in = cyl1(name='y max in', x0=x, y0=y+t)
12✔
1709

1710
                self.x_min_y_min_out = cyl2(name='x min y min out', x0=x-c*l, y0=y-l/2)
12✔
1711
                self.x_min_y_max_out = cyl2(name='x min y max out', x0=x-c*l, y0=y+l/2)
12✔
1712
                self.x_max_y_min_out = cyl2(name='x max y min out', x0=x+c*l, y0=y-l/2)
12✔
1713
                self.x_max_y_max_out = cyl2(name='x max y max out', x0=x+c*l, y0=y+l/2)
12✔
1714
                self.min_out = cyl2(name='y min out', x0=x, y0=y-l)
12✔
1715
                self.max_out = cyl2(name='y max out', x0=x, y0=y+l)
12✔
1716

1717
            # Add to tuple of surface names
1718
            for s in ('in', 'out'):
12✔
1719
                self._surface_names += (
12✔
1720
                    f'x_min_y_min_{s}', f'x_min_y_max_{s}',
1721
                    f'x_max_y_min_{s}', f'x_max_y_max_{s}',
1722
                    f'min_{s}', f'max_{s}')
1723

1724
    def __neg__(self) -> openmc.Region:
12✔
1725
        prism = (
12✔
1726
            -self.plane_max & +self.plane_min &
1727
            -self.upper_right & -self.upper_left &
1728
            +self.lower_right & +self.lower_left
1729
        )
1730

1731
        # Cut out corners if a corner radius was given
1732
        if hasattr(self, 'min_in'):
12✔
1733
            corners = (
12✔
1734
                +self.x_min_y_min_in & -self.x_min_y_min_out |
1735
                +self.x_min_y_max_in & -self.x_min_y_max_out |
1736
                +self.x_max_y_min_in & -self.x_max_y_min_out |
1737
                +self.x_max_y_max_in & -self.x_max_y_max_out |
1738
                +self.min_in & -self.min_out |
1739
                +self.max_in & -self.max_out
1740
            )
1741
            prism &= ~corners
12✔
1742

1743
        return prism
12✔
1744

1745

1746
def _rotation_matrix(v1, v2):
12✔
1747
    """Compute rotation matrix that would rotate v1 into v2.
1748

1749
    Parameters
1750
    ----------
1751
    v1 : numpy.ndarray
1752
        Unrotated vector
1753
    v2 : numpy.ndarray
1754
        Rotated vector
1755

1756
    Returns
1757
    -------
1758
    3x3 rotation matrix
1759

1760
    """
1761
    # Normalize vectors and compute cosine
1762
    u1 = v1 / np.linalg.norm(v1)
12✔
1763
    u2 = v2 / np.linalg.norm(v2)
12✔
1764
    cos_angle = np.dot(u1, u2)
12✔
1765

1766
    I = np.identity(3)
12✔
1767

1768
    # Handle special case where vectors are parallel or anti-parallel
1769
    if isclose(abs(cos_angle), 1.0, rel_tol=1e-8):
12✔
1770
        return np.sign(cos_angle)*I
12✔
1771
    else:
1772
        # Calculate rotation angle
UNCOV
1773
        sin_angle = np.sqrt(1 - cos_angle*cos_angle)
×
1774

1775
        # Calculate axis of rotation
UNCOV
1776
        axis = np.cross(u1, u2)
×
UNCOV
1777
        axis /= np.linalg.norm(axis)
×
1778

1779
        # Create cross-product matrix K
UNCOV
1780
        kx, ky, kz = axis
×
UNCOV
1781
        K = np.array([
×
1782
            [0.0, -kz, ky],
1783
            [kz, 0.0, -kx],
1784
            [-ky, kx, 0.0]
1785
        ])
1786

1787
        # Create rotation matrix using Rodrigues' rotation formula
UNCOV
1788
        return I + K * sin_angle + (K @ K) * (1 - cos_angle)
×
1789

1790

1791
class ConicalFrustum(CompositeSurface):
12✔
1792
    """Conical frustum.
1793

1794
    A conical frustum, also known as a right truncated cone, is a cone that is
1795
    truncated by two parallel planes that are perpendicular to the axis of the
1796
    cone. The lower and upper base of the conical frustum are circular faces.
1797
    This surface is equivalent to the TRC macrobody in MCNP.
1798

1799
    .. versionadded:: 0.15.1
1800

1801
    Parameters
1802
    ----------
1803
    center_base : iterable of float
1804
        Cartesian coordinates of the center of the bottom planar face.
1805
    axis : iterable of float
1806
        Vector from the center of the bottom planar face to the center of the
1807
        top planar face that defines the axis of the cone. The length of this
1808
        vector is the height of the conical frustum.
1809
    r1 : float
1810
        Radius of the lower cone base
1811
    r2 : float
1812
        Radius of the upper cone base
1813
    **kwargs
1814
        Keyword arguments passed to underlying plane classes
1815

1816
    Attributes
1817
    ----------
1818
    cone : openmc.Cone
1819
        Cone surface
1820
    plane_bottom : openmc.Plane
1821
        Plane surface defining the bottom of the frustum
1822
    plane_top : openmc.Plane
1823
        Plane surface defining the top of the frustum
1824

1825
    """
1826
    _surface_names = ('cone', 'plane_bottom', 'plane_top')
12✔
1827

1828
    def __init__(self, center_base: Sequence[float], axis: Sequence[float],
12✔
1829
                 r1: float, r2: float, **kwargs):
1830
        center_base = np.array(center_base)
12✔
1831
        axis = np.array(axis)
12✔
1832

1833
        # Determine length of axis height vector
1834
        h = np.linalg.norm(axis)
12✔
1835

1836
        # To create the frustum oriented with the correct axis, first we will
1837
        # create a cone along the z axis and then rotate it according to the
1838
        # given axis. Thus, we first need to determine the apex using the z axis
1839
        # as a reference.
1840
        x0, y0, z0 = center_base
12✔
1841
        if r1 != r2:
12✔
1842
            apex = z0 + r1*h/(r1 - r2)
12✔
1843
            r_sq = ((r1 - r2)/h)**2
12✔
1844
            cone = openmc.ZCone(x0, y0, apex, r2=r_sq, **kwargs)
12✔
1845
        else:
1846
            # In the degenerate case r1 == r2, the cone becomes a cylinder
1847
            cone = openmc.ZCylinder(x0, y0, r1, **kwargs)
12✔
1848

1849
        # Create the parallel planes
1850
        plane_bottom = openmc.ZPlane(z0, **kwargs)
12✔
1851
        plane_top = openmc.ZPlane(z0 + h, **kwargs)
12✔
1852

1853
        # Determine rotation matrix corresponding to specified axis
1854
        u = np.array([0., 0., 1.])
12✔
1855
        rotation = _rotation_matrix(u, axis)
12✔
1856

1857
        # Rotate the surfaces
1858
        self.cone = cone.rotate(rotation, pivot=center_base)
12✔
1859
        self.plane_bottom = plane_bottom.rotate(rotation, pivot=center_base)
12✔
1860
        self.plane_top = plane_top.rotate(rotation, pivot=center_base)
12✔
1861

1862
    def __neg__(self) -> openmc.Region:
12✔
1863
        return +self.plane_bottom & -self.plane_top & -self.cone
12✔
1864

1865

1866
class Vessel(CompositeSurface):
12✔
1867
    """Vessel composed of cylinder with semi-ellipsoid top and bottom.
1868

1869
    This composite surface is represented by a finite cylinder with ellipsoidal
1870
    top and bottom surfaces. This surface is equivalent to the 'vesesl' surface
1871
    in Serpent.
1872

1873
    .. versionadded:: 0.15.1
1874

1875
    Parameters
1876
    ----------
1877
    r : float
1878
        Radius of vessel.
1879
    p1 : float
1880
        Minimum coordinate for cylindrical part of vessel.
1881
    p2 : float
1882
        Maximum coordinate for cylindrical part of vessel.
1883
    h1 : float
1884
        Height of bottom ellipsoidal part of vessel.
1885
    h2 : float
1886
        Height of top ellipsoidal part of vessel.
1887
    center : 2-tuple of float
1888
        Coordinate for central axis of the cylinder in the (y, z), (x, z), or
1889
        (x, y) basis. Defaults to (0,0).
1890
    axis : {'x', 'y', 'z'}
1891
        Central axis of the cylinder.
1892

1893
    """
1894

1895
    _surface_names = ('cyl', 'plane_bottom', 'plane_top', 'bottom', 'top')
12✔
1896

1897
    def __init__(self, r: float, p1: float, p2: float, h1: float, h2: float,
12✔
1898
                 center: Sequence[float] = (0., 0.), axis: str = 'z', **kwargs):
1899
        if p1 >= p2:
12✔
UNCOV
1900
            raise ValueError('p1 must be less than p2')
×
1901
        check_value('axis', axis, {'x', 'y', 'z'})
12✔
1902

1903
        c1, c2 = center
12✔
1904
        cyl_class = getattr(openmc, f'{axis.upper()}Cylinder')
12✔
1905
        plane_class = getattr(openmc, f'{axis.upper()}Plane')
12✔
1906
        self.cyl = cyl_class(c1, c2, r, **kwargs)
12✔
1907
        self.plane_bottom = plane_class(p1)
12✔
1908
        self.plane_top = plane_class(p2)
12✔
1909

1910
        # General equation for an ellipsoid:
1911
        #   (x-x₀)²/r² + (y-y₀)²/r² + (z-z₀)²/h² = 1
1912
        #   (x-x₀)² + (y-y₀)² + (z-z₀)²s² = r²
1913
        # Let s = r/h:
1914
        #   (x² - 2x₀x + x₀²) + (y² - 2y₀y + y₀²) + (z² - 2z₀z + z₀²)s² = r²
1915
        #   x² + y² + s²z² - 2x₀x - 2y₀y - 2s²z₀z + (x₀² + y₀² + z₀²s² - r²) = 0
1916

1917
        sb = (r/h1)
12✔
1918
        st = (r/h2)
12✔
1919
        kwargs['a'] = kwargs['b'] = kwargs['c'] = 1.0
12✔
1920
        kwargs_bottom = kwargs
12✔
1921
        kwargs_top = kwargs.copy()
12✔
1922

1923
        sb2 = sb*sb
12✔
1924
        st2 = st*st
12✔
1925
        kwargs_bottom['k'] = c1*c1 + c2*c2 + p1*p1*sb2 - r*r
12✔
1926
        kwargs_top['k'] = c1*c1 + c2*c2 + p2*p2*st2 - r*r
12✔
1927

1928
        if axis == 'x':
12✔
UNCOV
1929
            kwargs_bottom['a'] *= sb2
×
UNCOV
1930
            kwargs_top['a'] *= st2
×
UNCOV
1931
            kwargs_bottom['g'] = -2*p1*sb2
×
UNCOV
1932
            kwargs_top['g'] = -2*p2*st2
×
UNCOV
1933
            kwargs_top['h'] = kwargs_bottom['h'] = -2*c1
×
UNCOV
1934
            kwargs_top['j'] = kwargs_bottom['j'] = -2*c2
×
1935
        elif axis == 'y':
12✔
UNCOV
1936
            kwargs_bottom['b'] *= sb2
×
UNCOV
1937
            kwargs_top['b'] *= st2
×
UNCOV
1938
            kwargs_top['g'] = kwargs_bottom['g'] = -2*c1
×
UNCOV
1939
            kwargs_bottom['h'] = -2*p1*sb2
×
UNCOV
1940
            kwargs_top['h'] = -2*p2*st2
×
UNCOV
1941
            kwargs_top['j'] = kwargs_bottom['j'] = -2*c2
×
1942
        elif axis == 'z':
12✔
1943
            kwargs_bottom['c'] *= sb2
12✔
1944
            kwargs_top['c'] *= st2
12✔
1945
            kwargs_top['g'] = kwargs_bottom['g'] = -2*c1
12✔
1946
            kwargs_top['h'] = kwargs_bottom['h'] = -2*c2
12✔
1947
            kwargs_bottom['j'] = -2*p1*sb2
12✔
1948
            kwargs_top['j'] = -2*p2*st2
12✔
1949

1950
        self.bottom = openmc.Quadric(**kwargs_bottom)
12✔
1951
        self.top = openmc.Quadric(**kwargs_top)
12✔
1952

1953
    def __neg__(self):
12✔
1954
        return ((-self.cyl & +self.plane_bottom & -self.plane_top) |
12✔
1955
                (-self.bottom & -self.plane_bottom) |
1956
                (-self.top & +self.plane_top))
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc