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

openmc-dev / openmc / 12776996362

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

Pull #3133

github

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

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

1658 existing lines in 66 files now uncovered.

50402 of 59340 relevant lines covered (84.94%)

33987813.96 hits per line

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

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

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

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

25

26
class SourceBase(ABC):
12✔
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__(
12✔
61
        self,
62
        strength: float | None = 1.0,
63
        constraints: dict[str, Any] | None = None
64
    ):
65
        self.strength = strength
12✔
66
        self.constraints = constraints
12✔
67

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

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

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

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

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

115
    @abstractmethod
12✔
116
    def populate_xml_element(self, element):
12✔
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:
12✔
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")
12✔
136
        element.set("type", self.type)
12✔
137
        if self.strength is not None:
12✔
138
            element.set("strength", str(self.strength))
12✔
139
        self.populate_xml_element(element)
12✔
140
        constraints = self.constraints
12✔
141
        if constraints:
12✔
142
            constraints_elem = ET.SubElement(element, "constraints")
12✔
143
            if "domain_ids" in constraints:
12✔
144
                dt_elem = ET.SubElement(constraints_elem, "domain_type")
12✔
145
                dt_elem.text = constraints["domain_type"]
12✔
146
                id_elem = ET.SubElement(constraints_elem, "domain_ids")
12✔
147
                id_elem.text = ' '.join(str(uid) for uid in constraints["domain_ids"])
12✔
148
            if "time_bounds" in constraints:
12✔
149
                dt_elem = ET.SubElement(constraints_elem, "time_bounds")
12✔
150
                dt_elem.text = ' '.join(str(t) for t in constraints["time_bounds"])
12✔
151
            if "energy_bounds" in constraints:
12✔
152
                dt_elem = ET.SubElement(constraints_elem, "energy_bounds")
12✔
153
                dt_elem.text = ' '.join(str(E) for E in constraints["energy_bounds"])
12✔
154
            if "fissionable" in constraints:
12✔
155
                dt_elem = ET.SubElement(constraints_elem, "fissionable")
12✔
156
                dt_elem.text = str(constraints["fissionable"]).lower()
12✔
157
            if "rejection_strategy" in constraints:
12✔
UNCOV
158
                dt_elem = ET.SubElement(constraints_elem, "rejection_strategy")
×
UNCOV
159
                dt_elem.text = constraints["rejection_strategy"]
×
160

161
        return element
12✔
162

163
    @classmethod
12✔
164
    def from_xml_element(cls, elem: ET.Element, meshes=None) -> SourceBase:
12✔
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')
12✔
182

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

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

210
        constraints = {}
12✔
211
        domain_type = get_text(elem, "domain_type")
12✔
212
        if domain_type is not None:
12✔
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':
×
UNCOV
222
                    domains = [openmc.Material(uid) for uid in domain_ids]
×
UNCOV
223
                elif domain_type == 'universe':
×
UNCOV
224
                    domains = [openmc.Universe(uid) for uid in domain_ids]
×
225
            constraints['domains'] = domains
×
226

227
        time_bounds = get_text(elem, "time_bounds")
12✔
228
        if time_bounds is not None:
12✔
229
            constraints['time_bounds'] = [float(x) for x in time_bounds.split()]
×
230

231
        energy_bounds = get_text(elem, "energy_bounds")
12✔
232
        if energy_bounds is not None:
12✔
UNCOV
233
            constraints['energy_bounds'] = [float(x) for x in energy_bounds.split()]
×
234

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

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

243
        return constraints
12✔
244

245

246
class IndependentSource(SourceBase):
12✔
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'}
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'}
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__(
12✔
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:
12✔
UNCOV
323
            warnings.warn("The 'domains' arguments has been replaced by the "
×
324
                          "'constraints' argument.", FutureWarning)
UNCOV
325
            constraints = {'domains': domains}
×
326

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

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

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

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

348
    def __getattr__(self, name):
12✔
349
        cls_names = {'file': 'FileSource', 'library': 'CompiledSource',
12✔
350
                     'parameters': 'CompiledSource'}
351
        if name in cls_names:
12✔
352
            raise AttributeError(
12✔
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)
12✔
357

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

446
        """
447
        constraints = cls._get_constraints(elem)
12✔
448
        source = cls(constraints=constraints)
12✔
449

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

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

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

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

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

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

474
        return source
12✔
475

476

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

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

487
    .. versionadded:: 0.15.0
488

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

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

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

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

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

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

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

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

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

564
        s = np.asarray(s)
12✔
565

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

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

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

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

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

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

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

602
        """
603
        current_strength = self.strength if self.strength != 0.0 else 1.0
12✔
604

605
        for s in self.sources:
12✔
606
            s.strength *= strength / current_strength
12✔
607

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

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

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

620
        """
621
        elem.set("mesh", str(self.mesh.id))
12✔
622

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

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

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

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

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

652

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

661

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

665
    .. versionadded:: 0.14.0
666

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

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

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

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

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

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

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

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

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

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

748
        """
749
        element.set("library", str(self.library))
12✔
750

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

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

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

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

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

775
        source = cls(**kwargs)
×
776

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

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

785
        return source
×
786

787

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

791
    .. versionadded:: 0.14.0
792

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

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

826
    """
827

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

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

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

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

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

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

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

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

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

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

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

886
        return cls(**kwargs)
×
887

888

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

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

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

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

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

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

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

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

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

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

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

959

960
class SourceParticle:
12✔
961
    """Source particle
962

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

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

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

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

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

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

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

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

1023

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

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

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

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

1048

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

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

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

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

1067
        Returns
1068
        -------
1069
        ParticleList instance
1070

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

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

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

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

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

1094
        Returns
1095
        -------
1096
        ParticleList instance
1097

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

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

UNCOV
1122
        return cls(particles)
×
1123

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1215

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

1219
    .. versionadded:: 0.15.0
1220

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

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

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

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

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

© 2025 Coveralls, Inc