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

openmc-dev / openmc / 19058781736

04 Nov 2025 05:26AM UTC coverage: 82.008% (-3.1%) from 85.155%
19058781736

Pull #3252

github

web-flow
Merge b8a72730f into bd76fc056
Pull Request #3252: Adding vtkhdf option to write vtk data

16714 of 23236 branches covered (71.93%)

Branch coverage included in aggregate %.

61 of 66 new or added lines in 1 file covered. (92.42%)

3175 existing lines in 103 files now uncovered.

54243 of 63288 relevant lines covered (85.71%)

43393337.77 hits per line

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

84.75
/openmc/source.py
1
from __future__ import annotations
11✔
2
from abc import ABC, abstractmethod
11✔
3
from collections.abc import Iterable, Sequence
11✔
4
from enum import IntEnum
11✔
5
from numbers import Real
11✔
6
from pathlib import Path
11✔
7
import warnings
11✔
8
from typing import Any
11✔
9
from pathlib import Path
11✔
10

11
import lxml.etree as ET
11✔
12
import numpy as np
11✔
13
import h5py
11✔
14
import pandas as pd
11✔
15

16
import openmc
11✔
17
import openmc.checkvalue as cv
11✔
18
from openmc.checkvalue import PathLike
11✔
19
from openmc.stats.multivariate import UnitSphere, Spatial
11✔
20
from openmc.stats.univariate import Univariate
11✔
21
from ._xml import get_elem_list, get_text
11✔
22
from .mesh import MeshBase, StructuredMesh, UnstructuredMesh
11✔
23
from .utility_funcs import input_path
11✔
24

25

26
class SourceBase(ABC):
11✔
27
    """Base class for external sources
28

29
    Parameters
30
    ----------
31
    strength : float
32
        Strength of the source
33
    constraints : dict
34
        Constraints on sampled source particles. Valid keys include 'domains',
35
        'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'.
36
        For 'domains', the corresponding value is an iterable of
37
        :class:`openmc.Cell`, :class:`openmc.Material`, or
38
        :class:`openmc.Universe` for which sampled sites must be within. For
39
        'time_bounds' and 'energy_bounds', the corresponding value is a sequence
40
        of floats giving the lower and upper bounds on time in [s] or energy in
41
        [eV] that the sampled particle must be within. For 'fissionable', the
42
        value is a bool indicating that only sites in fissionable material
43
        should be accepted. The 'rejection_strategy' indicates what should
44
        happen when a source particle is rejected: either 'resample' (pick a new
45
        particle) or 'kill' (accept and terminate).
46

47
    Attributes
48
    ----------
49
    type : {'independent', 'file', 'compiled', 'mesh'}
50
        Indicator of source type.
51
    strength : float
52
        Strength of the source
53
    constraints : dict
54
        Constraints on sampled source particles. Valid keys include
55
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
56
        'fissionable', and 'rejection_strategy'.
57

58
    """
59

60
    def __init__(
11✔
61
        self,
62
        strength: float | None = 1.0,
63
        constraints: dict[str, Any] | None = None
64
    ):
65
        self.strength = strength
11✔
66
        self.constraints = constraints
11✔
67

68
    @property
11✔
69
    def strength(self):
11✔
70
        return self._strength
11✔
71

72
    @strength.setter
11✔
73
    def strength(self, strength):
11✔
74
        cv.check_type('source strength', strength, Real, none_ok=True)
11✔
75
        if strength is not None:
11✔
76
            cv.check_greater_than('source strength', strength, 0.0, True)
11✔
77
        self._strength = strength
11✔
78

79
    @property
11✔
80
    def constraints(self) -> dict[str, Any]:
11✔
81
        return self._constraints
11✔
82

83
    @constraints.setter
11✔
84
    def constraints(self, constraints: dict[str, Any] | None):
11✔
85
        self._constraints = {}
11✔
86
        if constraints is None:
11✔
87
            return
11✔
88

89
        for key, value in constraints.items():
11✔
90
            if key == 'domains':
11✔
91
                cv.check_type('domains', value, Iterable,
11✔
92
                              (openmc.Cell, openmc.Material, openmc.Universe))
93
                if isinstance(value[0], openmc.Cell):
11✔
94
                    self._constraints['domain_type'] = 'cell'
11✔
95
                elif isinstance(value[0], openmc.Material):
11✔
96
                    self._constraints['domain_type'] = 'material'
×
97
                elif isinstance(value[0], openmc.Universe):
11✔
98
                    self._constraints['domain_type'] = 'universe'
11✔
99
                self._constraints['domain_ids'] = [d.id for d in value]
11✔
100
            elif key == 'time_bounds':
11✔
101
                cv.check_type('time bounds', value, Iterable, Real)
11✔
102
                self._constraints['time_bounds'] = tuple(value)
11✔
103
            elif key == 'energy_bounds':
11✔
104
                cv.check_type('energy bounds', value, Iterable, Real)
11✔
105
                self._constraints['energy_bounds'] = tuple(value)
11✔
106
            elif key == 'fissionable':
11✔
107
                cv.check_type('fissionable', value, bool)
11✔
108
                self._constraints['fissionable'] = value
11✔
109
            elif key == 'rejection_strategy':
×
110
                cv.check_value('rejection strategy', value, ('resample', 'kill'))
×
111
                self._constraints['rejection_strategy'] = value
×
112
            else:
113
                raise ValueError(f'Unknown key in constraints dictionary: {key}')
×
114

115
    @abstractmethod
11✔
116
    def populate_xml_element(self, element):
11✔
117
        """Add necessary source information to an XML element
118

119
        Returns
120
        -------
121
        element : lxml.etree._Element
122
            XML element containing source data
123

124
        """
125

126
    def to_xml_element(self) -> ET.Element:
11✔
127
        """Return XML representation of the source
128

129
        Returns
130
        -------
131
        element : xml.etree.ElementTree.Element
132
            XML element containing source data
133

134
        """
135
        element = ET.Element("source")
11✔
136
        element.set("type", self.type)
11✔
137
        if self.strength is not None:
11✔
138
            element.set("strength", str(self.strength))
11✔
139
        self.populate_xml_element(element)
11✔
140
        constraints = self.constraints
11✔
141
        if constraints:
11✔
142
            constraints_elem = ET.SubElement(element, "constraints")
11✔
143
            if "domain_ids" in constraints:
11✔
144
                dt_elem = ET.SubElement(constraints_elem, "domain_type")
11✔
145
                dt_elem.text = constraints["domain_type"]
11✔
146
                id_elem = ET.SubElement(constraints_elem, "domain_ids")
11✔
147
                id_elem.text = ' '.join(str(uid) for uid in constraints["domain_ids"])
11✔
148
            if "time_bounds" in constraints:
11✔
149
                dt_elem = ET.SubElement(constraints_elem, "time_bounds")
11✔
150
                dt_elem.text = ' '.join(str(t) for t in constraints["time_bounds"])
11✔
151
            if "energy_bounds" in constraints:
11✔
152
                dt_elem = ET.SubElement(constraints_elem, "energy_bounds")
11✔
153
                dt_elem.text = ' '.join(str(E) for E in constraints["energy_bounds"])
11✔
154
            if "fissionable" in constraints:
11✔
155
                dt_elem = ET.SubElement(constraints_elem, "fissionable")
11✔
156
                dt_elem.text = str(constraints["fissionable"]).lower()
11✔
157
            if "rejection_strategy" in constraints:
11✔
158
                dt_elem = ET.SubElement(constraints_elem, "rejection_strategy")
×
159
                dt_elem.text = constraints["rejection_strategy"]
×
160

161
        return element
11✔
162

163
    @classmethod
11✔
164
    def from_xml_element(cls, elem: ET.Element, meshes=None) -> SourceBase:
11✔
165
        """Generate source from an XML element
166

167
        Parameters
168
        ----------
169
        elem : lxml.etree._Element
170
            XML element
171
        meshes : dict
172
            Dictionary with mesh IDs as keys and openmc.MeshBase instances as
173
            values
174

175
        Returns
176
        -------
177
        openmc.SourceBase
178
            Source generated from XML element
179

180
        """
181
        source_type = get_text(elem, 'type')
11✔
182

183
        if source_type is None:
11✔
184
            # attempt to determine source type based on attributes
185
            # for backward compatibility
186
            if get_text(elem, 'file') is not None:
11✔
187
                return FileSource.from_xml_element(elem)
×
188
            elif get_text(elem, 'library') is not None:
11✔
189
                return CompiledSource.from_xml_element(elem)
×
190
            else:
191
                return IndependentSource.from_xml_element(elem)
11✔
192
        else:
193
            if source_type == 'independent':
11✔
194
                return IndependentSource.from_xml_element(elem, meshes)
11✔
195
            elif source_type == 'compiled':
11✔
196
                return CompiledSource.from_xml_element(elem)
×
197
            elif source_type == 'file':
11✔
198
                return FileSource.from_xml_element(elem)
×
199
            elif source_type == 'mesh':
11✔
200
                return MeshSource.from_xml_element(elem, meshes)
11✔
201
            else:
202
                raise ValueError(f'Source type {source_type} is not recognized')
×
203

204
    @staticmethod
11✔
205
    def _get_constraints(elem: ET.Element) -> dict[str, Any]:
11✔
206
        # Find element containing constraints
207
        constraints_elem = elem.find("constraints")
11✔
208
        elem = constraints_elem if constraints_elem is not None else elem
11✔
209

210
        constraints = {}
11✔
211
        domain_type = get_text(elem, "domain_type")
11✔
212
        if domain_type is not None:
11✔
213
            domain_ids = get_elem_list(elem, "domain_ids", int)
×
214

215
            # Instantiate some throw-away domains that are used by the
216
            # constructor to assign IDs
217
            with warnings.catch_warnings():
×
218
                warnings.simplefilter('ignore', openmc.IDWarning)
×
219
                if domain_type == 'cell':
×
220
                    domains = [openmc.Cell(uid) for uid in domain_ids]
×
221
                elif domain_type == 'material':
×
222
                    domains = [openmc.Material(uid) for uid in domain_ids]
×
223
                elif domain_type == 'universe':
×
224
                    domains = [openmc.Universe(uid) for uid in domain_ids]
×
225
            constraints['domains'] = domains
×
226

227
        time_bounds = get_elem_list(elem, "time_bounds", float)
11✔
228
        if time_bounds is not None:
11✔
229
            constraints['time_bounds'] = time_bounds
×
230

231
        energy_bounds = get_elem_list(elem, "energy_bounds", float)
11✔
232
        if energy_bounds is not None:
11✔
233
            constraints['energy_bounds'] = energy_bounds
×
234

235
        fissionable = get_text(elem, "fissionable")
11✔
236
        if fissionable is not None:
11✔
237
            constraints['fissionable'] = fissionable in ('true', '1')
11✔
238

239
        rejection_strategy = get_text(elem, "rejection_strategy")
11✔
240
        if rejection_strategy is not None:
11✔
241
            constraints['rejection_strategy'] = rejection_strategy
×
242

243
        return constraints
11✔
244

245

246
class IndependentSource(SourceBase):
11✔
247
    """Distribution of phase space coordinates for source sites.
248

249
    .. versionadded:: 0.14.0
250

251
    Parameters
252
    ----------
253
    space : openmc.stats.Spatial
254
        Spatial distribution of source sites
255
    angle : openmc.stats.UnitSphere
256
        Angular distribution of source sites
257
    energy : openmc.stats.Univariate
258
        Energy distribution of source sites
259
    time : openmc.stats.Univariate
260
        time distribution of source sites
261
    strength : float
262
        Strength of the source
263
    particle : {'neutron', 'photon', 'electron', 'positron'}
264
        Source particle type
265
    domains : iterable of openmc.Cell, openmc.Material, or openmc.Universe
266
        Domains to reject based on, i.e., if a sampled spatial location is not
267
        within one of these domains, it will be rejected.
268

269
        .. deprecated:: 0.15.0
270
            Use the `constraints` argument instead.
271
    constraints : dict
272
        Constraints on sampled source particles. Valid keys include 'domains',
273
        'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'.
274
        For 'domains', the corresponding value is an iterable of
275
        :class:`openmc.Cell`, :class:`openmc.Material`, or
276
        :class:`openmc.Universe` for which sampled sites must be within. For
277
        'time_bounds' and 'energy_bounds', the corresponding value is a sequence
278
        of floats giving the lower and upper bounds on time in [s] or energy in
279
        [eV] that the sampled particle must be within. For 'fissionable', the
280
        value is a bool indicating that only sites in fissionable material
281
        should be accepted. The 'rejection_strategy' indicates what should
282
        happen when a source particle is rejected: either 'resample' (pick a new
283
        particle) or 'kill' (accept and terminate).
284

285
    Attributes
286
    ----------
287
    space : openmc.stats.Spatial or None
288
        Spatial distribution of source sites
289
    angle : openmc.stats.UnitSphere or None
290
        Angular distribution of source sites
291
    energy : openmc.stats.Univariate or None
292
        Energy distribution of source sites
293
    time : openmc.stats.Univariate or None
294
        time distribution of source sites
295
    strength : float
296
        Strength of the source
297
    type : str
298
        Indicator of source type: 'independent'
299

300
    .. versionadded:: 0.14.0
301

302
    particle : {'neutron', 'photon', 'electron', 'positron'}
303
        Source particle type
304
    constraints : dict
305
        Constraints on sampled source particles. Valid keys include
306
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
307
        'fissionable', and 'rejection_strategy'.
308

309
    """
310

311
    def __init__(
11✔
312
        self,
313
        space: openmc.stats.Spatial | None = None,
314
        angle: openmc.stats.UnitSphere | None = None,
315
        energy: openmc.stats.Univariate | None = None,
316
        time: openmc.stats.Univariate | None = None,
317
        strength: float = 1.0,
318
        particle: str = 'neutron',
319
        domains: Sequence[openmc.Cell | openmc.Material | openmc.Universe] | None = None,
320
        constraints: dict[str, Any] | None = None
321
    ):
322
        if domains is not None:
11✔
323
            warnings.warn("The 'domains' arguments has been replaced by the "
×
324
                          "'constraints' argument.", FutureWarning)
325
            constraints = {'domains': domains}
×
326

327
        super().__init__(strength=strength, constraints=constraints)
11✔
328

329
        self._space = None
11✔
330
        self._angle = None
11✔
331
        self._energy = None
11✔
332
        self._time = None
11✔
333

334
        if space is not None:
11✔
335
            self.space = space
11✔
336
        if angle is not None:
11✔
337
            self.angle = angle
11✔
338
        if energy is not None:
11✔
339
            self.energy = energy
11✔
340
        if time is not None:
11✔
341
            self.time = time
11✔
342
        self.particle = particle
11✔
343

344
    @property
11✔
345
    def type(self) -> str:
11✔
346
        return 'independent'
11✔
347

348
    def __getattr__(self, name):
11✔
349
        cls_names = {'file': 'FileSource', 'library': 'CompiledSource',
11✔
350
                     'parameters': 'CompiledSource'}
351
        if name in cls_names:
11✔
352
            raise AttributeError(
11✔
353
                f'The "{name}" attribute has been deprecated on the '
354
                f'IndependentSource class. Please use the {cls_names[name]} class.')
355
        else:
356
            super().__getattribute__(name)
11✔
357

358
    def __setattr__(self, name, value):
11✔
359
        if name in ('file', 'library', 'parameters'):
11✔
360
            # Ensure proper AttributeError is thrown
361
            getattr(self, name)
11✔
362
        else:
363
            super().__setattr__(name, value)
11✔
364

365
    @property
11✔
366
    def space(self):
11✔
367
        return self._space
11✔
368

369
    @space.setter
11✔
370
    def space(self, space):
11✔
371
        cv.check_type('spatial distribution', space, Spatial)
11✔
372
        self._space = space
11✔
373

374
    @property
11✔
375
    def angle(self):
11✔
376
        return self._angle
11✔
377

378
    @angle.setter
11✔
379
    def angle(self, angle):
11✔
380
        cv.check_type('angular distribution', angle, UnitSphere)
11✔
381
        self._angle = angle
11✔
382

383
    @property
11✔
384
    def energy(self):
11✔
385
        return self._energy
11✔
386

387
    @energy.setter
11✔
388
    def energy(self, energy):
11✔
389
        cv.check_type('energy distribution', energy, Univariate)
11✔
390
        self._energy = energy
11✔
391

392
    @property
11✔
393
    def time(self):
11✔
394
        return self._time
11✔
395

396
    @time.setter
11✔
397
    def time(self, time):
11✔
398
        cv.check_type('time distribution', time, Univariate)
11✔
399
        self._time = time
11✔
400

401
    @property
11✔
402
    def particle(self):
11✔
403
        return self._particle
11✔
404

405
    @particle.setter
11✔
406
    def particle(self, particle):
11✔
407
        cv.check_value('source particle', particle,
11✔
408
                       ['neutron', 'photon', 'electron', 'positron'])
409
        self._particle = particle
11✔
410

411
    def populate_xml_element(self, element):
11✔
412
        """Add necessary source information to an XML element
413

414
        Returns
415
        -------
416
        element : lxml.etree._Element
417
            XML element containing source data
418

419
        """
420
        element.set("particle", self.particle)
11✔
421
        if self.space is not None:
11✔
422
            element.append(self.space.to_xml_element())
11✔
423
        if self.angle is not None:
11✔
424
            element.append(self.angle.to_xml_element())
11✔
425
        if self.energy is not None:
11✔
426
            element.append(self.energy.to_xml_element('energy'))
11✔
427
        if self.time is not None:
11✔
428
            element.append(self.time.to_xml_element('time'))
11✔
429

430
    @classmethod
11✔
431
    def from_xml_element(cls, elem: ET.Element, meshes=None) -> SourceBase:
11✔
432
        """Generate source from an XML element
433

434
        Parameters
435
        ----------
436
        elem : lxml.etree._Element
437
            XML element
438
        meshes : dict
439
            Dictionary with mesh IDs as keys and openmc.MeshBase instaces as
440
            values
441

442
        Returns
443
        -------
444
        openmc.Source
445
            Source generated from XML element
446

447
        """
448
        constraints = cls._get_constraints(elem)
11✔
449
        source = cls(constraints=constraints)
11✔
450

451
        strength = get_text(elem, 'strength')
11✔
452
        if strength is not None:
11✔
453
            source.strength = float(strength)
11✔
454

455
        particle = get_text(elem, 'particle')
11✔
456
        if particle is not None:
11✔
457
            source.particle = particle
11✔
458

459
        space = elem.find('space')
11✔
460
        if space is not None:
11✔
461
            source.space = Spatial.from_xml_element(space, meshes)
11✔
462

463
        angle = elem.find('angle')
11✔
464
        if angle is not None:
11✔
465
            source.angle = UnitSphere.from_xml_element(angle)
11✔
466

467
        energy = elem.find('energy')
11✔
468
        if energy is not None:
11✔
469
            source.energy = Univariate.from_xml_element(energy)
11✔
470

471
        time = elem.find('time')
11✔
472
        if time is not None:
11✔
UNCOV
473
            source.time = Univariate.from_xml_element(time)
×
474

475
        return source
11✔
476

477

478
class MeshSource(SourceBase):
11✔
479
    """A source with a spatial distribution over mesh elements
480

481
    This class represents a mesh-based source in which random positions are
482
    uniformly sampled within mesh elements and each element can have independent
483
    angle, energy, and time distributions. The element sampled is chosen based
484
    on the relative strengths of the sources applied to the elements. The
485
    strength of the mesh source as a whole is the sum of all source strengths
486
    applied to the elements.
487

488
    .. versionadded:: 0.15.0
489

490
    Parameters
491
    ----------
492
    mesh : openmc.MeshBase
493
        The mesh over which source sites will be generated.
494
    sources : sequence of openmc.SourceBase
495
        Sources for each element in the mesh. Sources must be specified as
496
        either a 1-D array in the order of the mesh indices or a
497
        multidimensional array whose shape matches the mesh shape. If spatial
498
        distributions are set on any of the source objects, they will be ignored
499
        during source site sampling.
500
    constraints : dict
501
        Constraints on sampled source particles. Valid keys include 'domains',
502
        'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'.
503
        For 'domains', the corresponding value is an iterable of
504
        :class:`openmc.Cell`, :class:`openmc.Material`, or
505
        :class:`openmc.Universe` for which sampled sites must be within. For
506
        'time_bounds' and 'energy_bounds', the corresponding value is a sequence
507
        of floats giving the lower and upper bounds on time in [s] or energy in
508
        [eV] that the sampled particle must be within. For 'fissionable', the
509
        value is a bool indicating that only sites in fissionable material
510
        should be accepted. The 'rejection_strategy' indicates what should
511
        happen when a source particle is rejected: either 'resample' (pick a new
512
        particle) or 'kill' (accept and terminate).
513

514
    Attributes
515
    ----------
516
    mesh : openmc.MeshBase
517
        The mesh over which source sites will be generated.
518
    sources : numpy.ndarray of openmc.SourceBase
519
        Sources to apply to each element
520
    strength : float
521
        Strength of the source
522
    type : str
523
        Indicator of source type: 'mesh'
524
    constraints : dict
525
        Constraints on sampled source particles. Valid keys include
526
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
527
        'fissionable', and 'rejection_strategy'.
528

529
    """
530
    def __init__(
11✔
531
            self,
532
            mesh: MeshBase,
533
            sources: Sequence[SourceBase],
534
            constraints: dict[str, Any] | None  = None,
535
    ):
536
        super().__init__(strength=None, constraints=constraints)
11✔
537
        self.mesh = mesh
11✔
538
        self.sources = sources
11✔
539

540
    @property
11✔
541
    def type(self) -> str:
11✔
542
        return "mesh"
11✔
543

544
    @property
11✔
545
    def mesh(self) -> MeshBase:
11✔
546
        return self._mesh
11✔
547

548
    @property
11✔
549
    def strength(self) -> float:
11✔
550
        return sum(s.strength for s in self.sources)
11✔
551

552
    @property
11✔
553
    def sources(self) -> np.ndarray:
11✔
554
        return self._sources
11✔
555

556
    @mesh.setter
11✔
557
    def mesh(self, m):
11✔
558
        cv.check_type('source mesh', m, MeshBase)
11✔
559
        self._mesh = m
11✔
560

561
    @sources.setter
11✔
562
    def sources(self, s):
11✔
563
        cv.check_iterable_type('mesh sources', s, SourceBase, max_depth=3)
11✔
564

565
        s = np.asarray(s)
11✔
566

567
        if isinstance(self.mesh, StructuredMesh):
11✔
568
            if s.size != self.mesh.num_mesh_cells:
11✔
UNCOV
569
                raise ValueError(
×
570
                    f'The length of the source array ({s.size}) does not match '
571
                    f'the number of mesh elements ({self.mesh.num_mesh_cells}).')
572

573
            # If user gave a multidimensional array, flatten in the order
574
            # of the mesh indices
575
            if s.ndim > 1:
11✔
576
                s = s.ravel(order='F')
11✔
577

578
        elif isinstance(self.mesh, UnstructuredMesh):
3✔
579
            if s.ndim > 1:
3✔
UNCOV
580
                raise ValueError('Sources must be a 1-D array for unstructured mesh')
×
581

582
        self._sources = s
11✔
583
        for src in self._sources:
11✔
584
            if isinstance(src, IndependentSource) and src.space is not None:
11✔
UNCOV
585
                warnings.warn('Some sources on the mesh have spatial '
×
586
                              'distributions that will be ignored at runtime.')
UNCOV
587
                break
×
588

589
    @strength.setter
11✔
590
    def strength(self, val):
11✔
591
        if val is not None:
11✔
592
            cv.check_type('mesh source strength', val, Real)
11✔
593
            self.set_total_strength(val)
11✔
594

595
    def set_total_strength(self, strength: float):
11✔
596
        """Scales the element source strengths based on a desired total strength.
597

598
        Parameters
599
        ----------
600
        strength : float
601
            Total source strength
602

603
        """
604
        current_strength = self.strength if self.strength != 0.0 else 1.0
11✔
605

606
        for s in self.sources:
11✔
607
            s.strength *= strength / current_strength
11✔
608

609
    def normalize_source_strengths(self):
11✔
610
        """Update all element source strengths such that they sum to 1.0."""
611
        self.set_total_strength(1.0)
11✔
612

613
    def populate_xml_element(self, elem: ET.Element):
11✔
614
        """Add necessary source information to an XML element
615

616
        Returns
617
        -------
618
        element : lxml.etree._Element
619
            XML element containing source data
620

621
        """
622
        elem.set("mesh", str(self.mesh.id))
11✔
623

624
        # write in the order of mesh indices
625
        for s in self.sources:
11✔
626
            elem.append(s.to_xml_element())
11✔
627

628
    @classmethod
11✔
629
    def from_xml_element(cls, elem: ET.Element, meshes) -> openmc.MeshSource:
11✔
630
        """
631
        Generate MeshSource from an XML element
632

633
        Parameters
634
        ----------
635
        elem : lxml.etree._Element
636
            XML element
637
        meshes : dict
638
            A dictionary with mesh IDs as keys and openmc.MeshBase instances as
639
            values
640

641
        Returns
642
        -------
643
        openmc.MeshSource
644
            MeshSource generated from the XML element
645
        """
646
        mesh_id = int(get_text(elem, 'mesh'))
11✔
647
        mesh = meshes[mesh_id]
11✔
648

649
        sources = [SourceBase.from_xml_element(e) for e in elem.iterchildren('source')]
11✔
650
        constraints = cls._get_constraints(elem)
11✔
651
        return cls(mesh, sources, constraints=constraints)
11✔
652

653

654
def Source(*args, **kwargs):
11✔
655
    """
656
    A function for backward compatibility of sources. Will be removed in the
657
    future. Please update to IndependentSource.
658
    """
659
    warnings.warn("This class is deprecated in favor of 'IndependentSource'", FutureWarning)
11✔
660
    return openmc.IndependentSource(*args, **kwargs)
11✔
661

662

663
class CompiledSource(SourceBase):
11✔
664
    """A source based on a compiled shared library
665

666
    .. versionadded:: 0.14.0
667

668
    Parameters
669
    ----------
670
    library : path-like
671
        Path to a compiled shared library
672
    parameters : str
673
        Parameters to be provided to the compiled shared library function
674
    strength : float
675
        Strength of the source
676
    constraints : dict
677
        Constraints on sampled source particles. Valid keys include 'domains',
678
        'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'.
679
        For 'domains', the corresponding value is an iterable of
680
        :class:`openmc.Cell`, :class:`openmc.Material`, or
681
        :class:`openmc.Universe` for which sampled sites must be within. For
682
        'time_bounds' and 'energy_bounds', the corresponding value is a sequence
683
        of floats giving the lower and upper bounds on time in [s] or energy in
684
        [eV] that the sampled particle must be within. For 'fissionable', the
685
        value is a bool indicating that only sites in fissionable material
686
        should be accepted. The 'rejection_strategy' indicates what should
687
        happen when a source particle is rejected: either 'resample' (pick a new
688
        particle) or 'kill' (accept and terminate).
689

690
    Attributes
691
    ----------
692
    library : pathlib.Path
693
        Path to a compiled shared library
694
    parameters : str
695
        Parameters to be provided to the compiled shared library function
696
    strength : float
697
        Strength of the source
698
    type : str
699
        Indicator of source type: 'compiled'
700
    constraints : dict
701
        Constraints on sampled source particles. Valid keys include
702
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
703
        'fissionable', and 'rejection_strategy'.
704

705
    """
706
    def __init__(
11✔
707
        self,
708
        library: PathLike,
709
        parameters: str | None = None,
710
        strength: float = 1.0,
711
        constraints: dict[str, Any] | None = None
712
    ) -> None:
713
        super().__init__(strength=strength, constraints=constraints)
11✔
714
        self.library = library
11✔
715
        self._parameters = None
11✔
716
        if parameters is not None:
11✔
UNCOV
717
            self.parameters = parameters
×
718

719
    @property
11✔
720
    def type(self) -> str:
11✔
721
        return "compiled"
11✔
722

723
    @property
11✔
724
    def library(self) -> Path:
11✔
725
        return self._library
11✔
726

727
    @library.setter
11✔
728
    def library(self, library_name: PathLike):
11✔
729
        cv.check_type('library', library_name, PathLike)
11✔
730
        self._library = input_path(library_name)
11✔
731

732
    @property
11✔
733
    def parameters(self) -> str:
11✔
734
        return self._parameters
11✔
735

736
    @parameters.setter
11✔
737
    def parameters(self, parameters_path):
11✔
738
        cv.check_type('parameters', parameters_path, str)
11✔
739
        self._parameters = parameters_path
11✔
740

741
    def populate_xml_element(self, element):
11✔
742
        """Add necessary compiled source information to an XML element
743

744
        Returns
745
        -------
746
        element : lxml.etree._Element
747
            XML element containing source data
748

749
        """
750
        element.set("library", str(self.library))
11✔
751

752
        if self.parameters is not None:
11✔
753
            element.set("parameters", self.parameters)
11✔
754

755
    @classmethod
11✔
756
    def from_xml_element(cls, elem: ET.Element) -> openmc.CompiledSource:
11✔
757
        """Generate a compiled source from an XML element
758

759
        Parameters
760
        ----------
761
        elem : lxml.etree._Element
762
            XML element
763
        meshes : dict
764
            Dictionary with mesh IDs as keys and openmc.MeshBase instances as
765
            values
766

767
        Returns
768
        -------
769
        openmc.CompiledSource
770
            Source generated from XML element
771

772
        """
773
        kwargs = {'constraints': cls._get_constraints(elem)}
×
UNCOV
774
        kwargs['library'] = get_text(elem, 'library')
×
775

UNCOV
776
        source = cls(**kwargs)
×
777

778
        strength = get_text(elem, 'strength')
×
779
        if strength is not None:
×
UNCOV
780
            source.strength = float(strength)
×
781

782
        parameters = get_text(elem, 'parameters')
×
783
        if parameters is not None:
×
UNCOV
784
            source.parameters = parameters
×
785

UNCOV
786
        return source
×
787

788

789
class FileSource(SourceBase):
11✔
790
    """A source based on particles stored in a file
791

792
    .. versionadded:: 0.14.0
793

794
    Parameters
795
    ----------
796
    path : path-like
797
        Path to the source file from which sites should be sampled
798
    strength : float
799
        Strength of the source (default is 1.0)
800
    constraints : dict
801
        Constraints on sampled source particles. Valid keys include 'domains',
802
        'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'.
803
        For 'domains', the corresponding value is an iterable of
804
        :class:`openmc.Cell`, :class:`openmc.Material`, or
805
        :class:`openmc.Universe` for which sampled sites must be within. For
806
        'time_bounds' and 'energy_bounds', the corresponding value is a sequence
807
        of floats giving the lower and upper bounds on time in [s] or energy in
808
        [eV] that the sampled particle must be within. For 'fissionable', the
809
        value is a bool indicating that only sites in fissionable material
810
        should be accepted. The 'rejection_strategy' indicates what should
811
        happen when a source particle is rejected: either 'resample' (pick a new
812
        particle) or 'kill' (accept and terminate).
813

814
    Attributes
815
    ----------
816
    path : Pathlike
817
        Source file from which sites should be sampled
818
    strength : float
819
        Strength of the source
820
    type : str
821
        Indicator of source type: 'file'
822
    constraints : dict
823
        Constraints on sampled source particles. Valid keys include
824
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
825
        'fissionable', and 'rejection_strategy'.
826

827
    """
828

829
    def __init__(
11✔
830
        self,
831
        path: PathLike,
832
        strength: float = 1.0,
833
        constraints: dict[str, Any] | None = None
834
    ):
835
        super().__init__(strength=strength, constraints=constraints)
11✔
836
        self.path = path
11✔
837

838
    @property
11✔
839
    def type(self) -> str:
11✔
840
        return "file"
11✔
841

842
    @property
11✔
843
    def path(self) -> PathLike:
11✔
844
        return self._path
11✔
845

846
    @path.setter
11✔
847
    def path(self, p: PathLike):
11✔
848
        cv.check_type('source file', p, PathLike)
11✔
849
        self._path = input_path(p)
11✔
850

851
    def populate_xml_element(self, element):
11✔
852
        """Add necessary file source information to an XML element
853

854
        Returns
855
        -------
856
        element : lxml.etree._Element
857
            XML element containing source data
858

859
        """
860
        if self.path is not None:
11✔
861
            element.set("file", str(self.path))
11✔
862

863
    @classmethod
11✔
864
    def from_xml_element(cls, elem: ET.Element) -> openmc.FileSource:
11✔
865
        """Generate file source from an XML element
866

867
        Parameters
868
        ----------
869
        elem : lxml.etree._Element
870
            XML element
871
        meshes : dict
872
            Dictionary with mesh IDs as keys and openmc.MeshBase instances as
873
            values
874

875
        Returns
876
        -------
877
        openmc.FileSource
878
            Source generated from XML element
879

880
        """
881
        kwargs = {'constraints': cls._get_constraints(elem)}
×
882
        kwargs['path'] = get_text(elem, 'file')
×
883
        strength = get_text(elem, 'strength')
×
884
        if strength is not None:
×
UNCOV
885
            kwargs['strength'] = float(strength)
×
886

UNCOV
887
        return cls(**kwargs)
×
888

889

890
class ParticleType(IntEnum):
11✔
891
    """
892
    IntEnum class representing a particle type. Type
893
    values mirror those found in the C++ class.
894
    """
895
    NEUTRON = 0
11✔
896
    PHOTON = 1
11✔
897
    ELECTRON = 2
11✔
898
    POSITRON = 3
11✔
899

900
    @classmethod
11✔
901
    def from_string(cls, value: str):
11✔
902
        """
903
        Constructs a ParticleType instance from a string.
904

905
        Parameters
906
        ----------
907
        value : str
908
            The string representation of the particle type.
909

910
        Returns
911
        -------
912
        The corresponding ParticleType instance.
913
        """
914
        try:
11✔
915
            return cls[value.upper()]
11✔
916
        except KeyError:
11✔
917
            raise ValueError(f"Invalid string for creation of {cls.__name__}: {value}")
11✔
918

919
    @classmethod
11✔
920
    def from_pdg_number(cls, pdg_number: int) -> ParticleType:
11✔
921
        """Constructs a ParticleType instance from a PDG number.
922

923
        The Particle Data Group at LBNL publishes a Monte Carlo particle
924
        numbering scheme as part of the `Review of Particle Physics
925
        <10.1103/PhysRevD.110.030001>`_. This method maps PDG numbers to the
926
        corresponding :class:`ParticleType`.
927

928
        Parameters
929
        ----------
930
        pdg_number : int
931
            The PDG number of the particle type.
932

933
        Returns
934
        -------
935
        The corresponding ParticleType instance.
936
        """
937
        try:
×
UNCOV
938
            return {
×
939
                2112: ParticleType.NEUTRON,
940
                22: ParticleType.PHOTON,
941
                11: ParticleType.ELECTRON,
942
                -11: ParticleType.POSITRON,
943
            }[pdg_number]
944
        except KeyError:
×
UNCOV
945
            raise ValueError(f"Unrecognized PDG number: {pdg_number}")
×
946

947
    def __repr__(self) -> str:
11✔
948
        """
949
        Returns a string representation of the ParticleType instance.
950

951
        Returns:
952
            str: The lowercase name of the ParticleType instance.
953
        """
954
        return self.name.lower()
11✔
955

956
    # needed for < Python 3.11
957
    def __str__(self) -> str:
11✔
958
        return self.__repr__()
11✔
959

960

961
class SourceParticle:
11✔
962
    """Source particle
963

964
    This class can be used to create source particles that can be written to a
965
    file and used by OpenMC
966

967
    Parameters
968
    ----------
969
    r : iterable of float
970
        Position of particle in Cartesian coordinates
971
    u : iterable of float
972
        Directional cosines
973
    E : float
974
        Energy of particle in [eV]
975
    time : float
976
        Time of particle in [s]
977
    wgt : float
978
        Weight of the particle
979
    delayed_group : int
980
        Delayed group particle was created in (neutrons only)
981
    surf_id : int
982
        Surface ID where particle is at, if any.
983
    particle : ParticleType
984
        Type of the particle
985

986
    """
987
    def __init__(
11✔
988
        self,
989
        r: Iterable[float] = (0., 0., 0.),
990
        u: Iterable[float] = (0., 0., 1.),
991
        E: float = 1.0e6,
992
        time: float = 0.0,
993
        wgt: float = 1.0,
994
        delayed_group: int = 0,
995
        surf_id: int = 0,
996
        particle: ParticleType = ParticleType.NEUTRON
997
    ):
998

999
        self.r = tuple(r)
11✔
1000
        self.u = tuple(u)
11✔
1001
        self.E = float(E)
11✔
1002
        self.time = float(time)
11✔
1003
        self.wgt = float(wgt)
11✔
1004
        self.delayed_group = delayed_group
11✔
1005
        self.surf_id = surf_id
11✔
1006
        self.particle = particle
11✔
1007

1008
    def __repr__(self):
11✔
1009
        name = self.particle.name.lower()
×
UNCOV
1010
        return f'<SourceParticle: {name} at E={self.E:.6e} eV>'
×
1011

1012
    def to_tuple(self) -> tuple:
11✔
1013
        """Return source particle attributes as a tuple
1014

1015
        Returns
1016
        -------
1017
        tuple
1018
            Source particle attributes
1019

1020
        """
1021
        return (self.r, self.u, self.E, self.time, self.wgt,
11✔
1022
                self.delayed_group, self.surf_id, self.particle.value)
1023

1024

1025
def write_source_file(
11✔
1026
    source_particles: Iterable[SourceParticle],
1027
    filename: PathLike, **kwargs
1028
):
1029
    """Write a source file using a collection of source particles
1030

1031
    Parameters
1032
    ----------
1033
    source_particles : iterable of SourceParticle
1034
        Source particles to write to file
1035
    filename : str or path-like
1036
        Path to source file to write
1037
    **kwargs
1038
        Keyword arguments to pass to :class:`h5py.File`
1039

1040
    See Also
1041
    --------
1042
    openmc.SourceParticle
1043

1044
    """
1045
    cv.check_iterable_type("source particles", source_particles, SourceParticle)
11✔
1046
    pl = ParticleList(source_particles)
11✔
1047
    pl.export_to_hdf5(filename, **kwargs)
11✔
1048

1049

1050
class ParticleList(list):
11✔
1051
    """A collection of SourceParticle objects.
1052

1053
    Parameters
1054
    ----------
1055
    particles : list of SourceParticle
1056
        Particles to collect into the list
1057

1058
    """
1059
    @classmethod
11✔
1060
    def from_hdf5(cls, filename: PathLike) -> ParticleList:
11✔
1061
        """Create particle list from an HDF5 file.
1062

1063
        Parameters
1064
        ----------
1065
        filename : path-like
1066
            Path to source file to read.
1067

1068
        Returns
1069
        -------
1070
        ParticleList instance
1071

1072
        """
1073
        with h5py.File(filename, 'r') as fh:
11✔
1074
            filetype = fh.attrs['filetype']
11✔
1075
            arr = fh['source_bank'][...]
11✔
1076

1077
        if filetype != b'source':
11✔
UNCOV
1078
            raise ValueError(f'File {filename} is not a source file')
×
1079

1080
        source_particles = [
11✔
1081
            SourceParticle(*params, ParticleType(particle))
1082
            for *params, particle in arr
1083
        ]
1084
        return cls(source_particles)
11✔
1085

1086
    @classmethod
11✔
1087
    def from_mcpl(cls, filename: PathLike) -> ParticleList:
11✔
1088
        """Create particle list from an MCPL file.
1089

1090
        Parameters
1091
        ----------
1092
        filename : path-like
1093
            Path to MCPL file to read.
1094

1095
        Returns
1096
        -------
1097
        ParticleList instance
1098

1099
        """
UNCOV
1100
        import mcpl
×
1101
        # Process .mcpl file
1102
        particles = []
×
1103
        with mcpl.MCPLFile(filename) as f:
×
UNCOV
1104
            for particle in f.particles:
×
1105
                # Determine particle type based on the PDG number
1106
                try:
×
1107
                    particle_type = ParticleType.from_pdg_number(particle.pdgcode)
×
1108
                except ValueError:
×
UNCOV
1109
                    particle_type = "UNKNOWN"
×
1110

1111
                # Create a source particle instance. Note that MCPL stores
1112
                # energy in MeV and time in ms.
UNCOV
1113
                source_particle = SourceParticle(
×
1114
                    r=tuple(particle.position),
1115
                    u=tuple(particle.direction),
1116
                    E=1.0e6*particle.ekin,
1117
                    time=1.0e-3*particle.time,
1118
                    wgt=particle.weight,
1119
                    particle=particle_type
1120
                )
UNCOV
1121
                particles.append(source_particle)
×
1122

UNCOV
1123
        return cls(particles)
×
1124

1125
    def __getitem__(self, index):
11✔
1126
        """
1127
        Return a new ParticleList object containing the particle(s)
1128
        at the specified index or slice.
1129

1130
        Parameters
1131
        ----------
1132
        index : int, slice or list
1133
            The index, slice or list to select from the list of particles
1134

1135
        Returns
1136
        -------
1137
        openmc.ParticleList or openmc.SourceParticle
1138
            A new object with the selected particle(s)
1139
        """
1140
        if isinstance(index, int):
11✔
1141
            # If it's a single integer, return the corresponding particle
1142
            return super().__getitem__(index)
11✔
1143
        elif isinstance(index, slice):
11✔
1144
            # If it's a slice, return a new ParticleList object with the
1145
            # sliced particles
1146
            return ParticleList(super().__getitem__(index))
11✔
1147
        elif isinstance(index, list):
11✔
1148
            # If it's a list of integers, return a new ParticleList object with
1149
            # the selected particles. Note that Python 3.10 gets confused if you
1150
            # use super() here, so we call list.__getitem__ directly.
1151
            return ParticleList([list.__getitem__(self, i) for i in index])
11✔
1152
        else:
UNCOV
1153
            raise TypeError(f"Invalid index type: {type(index)}. Must be int, "
×
1154
                            "slice, or list of int.")
1155

1156
    def to_dataframe(self) -> pd.DataFrame:
11✔
1157
        """A dataframe representing the source particles
1158

1159
        Returns
1160
        -------
1161
        pandas.DataFrame
1162
            DataFrame containing the source particles attributes.
1163
        """
1164
        # Extract the attributes of the source particles into a list of tuples
1165
        data = [(sp.r[0], sp.r[1], sp.r[2], sp.u[0], sp.u[1], sp.u[2],
11✔
1166
                 sp.E, sp.time, sp.wgt, sp.delayed_group, sp.surf_id,
1167
                 sp.particle.name.lower()) for sp in self]
1168

1169
        # Define the column names for the DataFrame
1170
        columns = ['x', 'y', 'z', 'u_x', 'u_y', 'u_z', 'E', 'time', 'wgt',
11✔
1171
                   'delayed_group', 'surf_id', 'particle']
1172

1173
        # Create the pandas DataFrame from the data
1174
        return pd.DataFrame(data, columns=columns)
11✔
1175

1176
    def export_to_hdf5(self, filename: PathLike, **kwargs):
11✔
1177
        """Export particle list to an HDF5 file.
1178

1179
        This method write out an .h5 file that can be used as a source file in
1180
        conjunction with the :class:`openmc.FileSource` class.
1181

1182
        Parameters
1183
        ----------
1184
        filename : path-like
1185
            Path to source file to write
1186
        **kwargs
1187
            Keyword arguments to pass to :class:`h5py.File`
1188

1189
        See Also
1190
        --------
1191
        openmc.FileSource
1192

1193
        """
1194
        # Create compound datatype for source particles
1195
        pos_dtype = np.dtype([('x', '<f8'), ('y', '<f8'), ('z', '<f8')])
11✔
1196
        source_dtype = np.dtype([
11✔
1197
            ('r', pos_dtype),
1198
            ('u', pos_dtype),
1199
            ('E', '<f8'),
1200
            ('time', '<f8'),
1201
            ('wgt', '<f8'),
1202
            ('delayed_group', '<i4'),
1203
            ('surf_id', '<i4'),
1204
            ('particle', '<i4'),
1205
        ])
1206

1207
        # Create array of source particles
1208
        arr = np.array([s.to_tuple() for s in self], dtype=source_dtype)
11✔
1209

1210
        # Write array to file
1211
        kwargs.setdefault('mode', 'w')
11✔
1212
        with h5py.File(filename, **kwargs) as fh:
11✔
1213
            fh.attrs['filetype'] = np.bytes_("source")
11✔
1214
            fh.create_dataset('source_bank', data=arr, dtype=source_dtype)
11✔
1215

1216

1217
def read_source_file(filename: PathLike) -> ParticleList:
11✔
1218
    """Read a source file and return a list of source particles.
1219

1220
    .. versionadded:: 0.15.0
1221

1222
    Parameters
1223
    ----------
1224
    filename : str or path-like
1225
        Path to source file to read
1226

1227
    Returns
1228
    -------
1229
    openmc.ParticleList
1230

1231
    See Also
1232
    --------
1233
    openmc.SourceParticle
1234

1235
    """
1236
    filename = Path(filename)
11✔
1237
    if filename.suffix not in ('.h5', '.mcpl'):
11✔
UNCOV
1238
        raise ValueError('Source file must have a .h5 or .mcpl extension.')
×
1239

1240
    if filename.suffix == '.h5':
11✔
1241
        return ParticleList.from_hdf5(filename)
11✔
1242
    else:
UNCOV
1243
        return ParticleList.from_mcpl(filename)
×
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

© 2026 Coveralls, Inc