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

openmc-dev / openmc / 15900897590

26 Jun 2025 11:42AM UTC coverage: 85.247% (-0.005%) from 85.252%
15900897590

Pull #3404

github

web-flow
Merge 275a6be63 into 5c1021446
Pull Request #3404: New Feature: electron/positron independent source.

18 of 24 new or added lines in 5 files covered. (75.0%)

47 existing lines in 2 files now uncovered.

52607 of 61711 relevant lines covered (85.25%)

36721892.78 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_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 = [int(x) for x in get_text(elem, "domain_ids").split()]
×
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_text(elem, "time_bounds")
11✔
228
        if time_bounds is not None:
11✔
229
            constraints['time_bounds'] = [float(x) for x in time_bounds.split()]
×
230

231
        energy_bounds = get_text(elem, "energy_bounds")
11✔
232
        if energy_bounds is not None:
11✔
233
            constraints['energy_bounds'] = [float(x) for x in energy_bounds.split()]
×
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',
11✔
408
                       particle,
409
                       ['neutron', 'photon', 'electron', 'positron'])
410
        self._particle = particle
11✔
411

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

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

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

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

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

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

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

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

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

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

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

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

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

476
        return source
11✔
477

478

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

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

489
    .. versionadded:: 0.15.0
490

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

654

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

663

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

667
    .. versionadded:: 0.14.0
668

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
787
        return source
×
788

789

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

793
    .. versionadded:: 0.14.0
794

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

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

828
    """
829

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

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

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

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

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

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

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

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

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

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

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

UNCOV
888
        return cls(**kwargs)
×
889

890

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

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

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

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

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

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

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

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

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

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

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

961

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

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

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

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

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

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

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

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

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

1025

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

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

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

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

1050

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

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

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

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

1069
        Returns
1070
        -------
1071
        ParticleList instance
1072

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

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

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

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

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

1096
        Returns
1097
        -------
1098
        ParticleList instance
1099

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

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

UNCOV
1124
        return cls(particles)
×
1125

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1217

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

1221
    .. versionadded:: 0.15.0
1222

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

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

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

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

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