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

openmc-dev / openmc / 29853401276

21 Jul 2026 05:32PM UTC coverage: 81.401% (+0.1%) from 81.305%
29853401276

Pull #3971

github

web-flow
Merge 67fad0e20 into 852f92780
Pull Request #3971: Delta tracking

18712 of 27078 branches covered (69.1%)

Branch coverage included in aggregate %.

611 of 658 new or added lines in 20 files covered. (92.86%)

534 existing lines in 12 files now uncovered.

60459 of 70182 relevant lines covered (86.15%)

49012636.16 hits per line

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

90.38
/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 numbers import Integral, Real
11✔
5
from pathlib import Path
11✔
6
import warnings
11✔
7
from typing import Any
11✔
8

9
import lxml.etree as ET
11✔
10
import numpy as np
11✔
11
import h5py
11✔
12
import pandas as pd
11✔
13

14
import openmc
11✔
15
import openmc.checkvalue as cv
11✔
16
from openmc.checkvalue import PathLike
11✔
17
from openmc.stats.multivariate import UnitSphere, Spatial
11✔
18
from openmc.stats.univariate import Univariate
11✔
19
from ._xml import get_elem_list, get_text
11✔
20
from .mesh import MeshBase, StructuredMesh, UnstructuredMesh
11✔
21
from .particle_type import ParticleType
11✔
22
from .statepoint import _VERSION_STATEPOINT
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', 'tokamak'}
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'
11✔
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',
×
111
                               value, ('resample', 'kill'))
112
                self._constraints['rejection_strategy'] = value
×
113
            else:
114
                raise ValueError(
×
115
                    f'Unknown key in constraints dictionary: {key}')
116

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

121
        Returns
122
        -------
123
        element : lxml.etree._Element
124
            XML element containing source data
125

126
        """
127

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

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

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

166
        return element
11✔
167

168
    @classmethod
11✔
169
    def from_xml_element(cls, elem: ET.Element, meshes=None) -> SourceBase:
11✔
170
        """Generate source from an XML element
171

172
        Parameters
173
        ----------
174
        elem : lxml.etree._Element
175
            XML element
176
        meshes : dict
177
            Dictionary with mesh IDs as keys and openmc.MeshBase instances as
178
            values
179

180
        Returns
181
        -------
182
        openmc.SourceBase
183
            Source generated from XML element
184

185
        """
186
        source_type = get_text(elem, 'type')
11✔
187

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

212
    @staticmethod
11✔
213
    def _get_constraints(elem: ET.Element) -> dict[str, Any]:
11✔
214
        # Find element containing constraints
215
        constraints_elem = elem.find("constraints")
11✔
216
        elem = constraints_elem if constraints_elem is not None else elem
11✔
217

218
        constraints = {}
11✔
219
        domain_type = get_text(elem, "domain_type")
11✔
220
        if domain_type is not None:
11✔
UNCOV
221
            domain_ids = get_elem_list(elem, "domain_ids", int)
×
222

223
            # Instantiate some throw-away domains that are used by the
224
            # constructor to assign IDs
225
            with warnings.catch_warnings():
×
226
                warnings.simplefilter('ignore', openmc.IDWarning)
×
227
                if domain_type == 'cell':
×
228
                    domains = [openmc.Cell(uid) for uid in domain_ids]
×
229
                elif domain_type == 'material':
×
230
                    domains = [openmc.Material(uid) for uid in domain_ids]
×
231
                elif domain_type == 'universe':
×
UNCOV
232
                    domains = [openmc.Universe(uid) for uid in domain_ids]
×
UNCOV
233
            constraints['domains'] = domains
×
234

235
        time_bounds = get_elem_list(elem, "time_bounds", float)
11✔
236
        if time_bounds is not None:
11✔
UNCOV
237
            constraints['time_bounds'] = time_bounds
×
238

239
        energy_bounds = get_elem_list(elem, "energy_bounds", float)
11✔
240
        if energy_bounds is not None:
11✔
UNCOV
241
            constraints['energy_bounds'] = energy_bounds
×
242

243
        fissionable = get_text(elem, "fissionable")
11✔
244
        if fissionable is not None:
11✔
245
            constraints['fissionable'] = fissionable in ('true', '1')
11✔
246

247
        rejection_strategy = get_text(elem, "rejection_strategy")
11✔
248
        if rejection_strategy is not None:
11✔
UNCOV
249
            constraints['rejection_strategy'] = rejection_strategy
×
250

251
        return constraints
11✔
252

253

254
class IndependentSource(SourceBase):
11✔
255
    """Distribution of phase space coordinates for source sites.
256

257
    .. versionadded:: 0.14.0
258

259
    Parameters
260
    ----------
261
    space : openmc.stats.Spatial
262
        Spatial distribution of source sites
263
    angle : openmc.stats.UnitSphere
264
        Angular distribution of source sites
265
    energy : openmc.stats.Univariate
266
        Energy distribution of source sites
267
    time : openmc.stats.Univariate
268
        time distribution of source sites
269
    strength : float
270
        Strength of the source
271
    particle : str or int or openmc.ParticleType
272
        Source particle type (name, PDG number, or type)
273
    domains : iterable of openmc.Cell, openmc.Material, or openmc.Universe
274
        Domains to reject based on, i.e., if a sampled spatial location is not
275
        within one of these domains, it will be rejected.
276

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

293
    Attributes
294
    ----------
295
    space : openmc.stats.Spatial or None
296
        Spatial distribution of source sites
297
    angle : openmc.stats.UnitSphere or None
298
        Angular distribution of source sites
299
    energy : openmc.stats.Univariate or None
300
        Energy distribution of source sites
301
    time : openmc.stats.Univariate or None
302
        time distribution of source sites
303
    strength : float
304
        Strength of the source
305
    type : str
306
        Indicator of source type: 'independent'
307

308
        .. versionadded:: 0.14.0
309
    particle : str or int or openmc.ParticleType
310
        Source particle type (alias, PDG number, or GNDS nuclide name)
311
    constraints : dict
312
        Constraints on sampled source particles. Valid keys include
313
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
314
        'fissionable', and 'rejection_strategy'.
315

316
    """
317

318
    def __init__(
11✔
319
        self,
320
        space: openmc.stats.Spatial | None = None,
321
        angle: openmc.stats.UnitSphere | None = None,
322
        energy: openmc.stats.Univariate | None = None,
323
        time: openmc.stats.Univariate | None = None,
324
        strength: float = 1.0,
325
        particle: str | int | ParticleType = 'neutron',
326
        domains: Sequence[openmc.Cell | openmc.Material |
327
                          openmc.Universe] | None = None,
328
        constraints: dict[str, Any] | None = None
329
    ):
330
        if domains is not None:
11✔
331
            warnings.warn("The 'domains' arguments has been replaced by the "
×
332
                          "'constraints' argument.", FutureWarning)
UNCOV
333
            constraints = {'domains': domains}
×
334

335
        super().__init__(strength=strength, constraints=constraints)
11✔
336

337
        self._space = None
11✔
338
        self._angle = None
11✔
339
        self._energy = None
11✔
340
        self._time = None
11✔
341

342
        if space is not None:
11✔
343
            self.space = space
11✔
344
        if angle is not None:
11✔
345
            self.angle = angle
11✔
346
        if energy is not None:
11✔
347
            self.energy = energy
11✔
348
        if time is not None:
11✔
349
            self.time = time
11✔
350
        self.particle = particle
11✔
351

352
    @property
11✔
353
    def type(self) -> str:
11✔
354
        return 'independent'
11✔
355

356
    def __getattr__(self, name):
11✔
357
        cls_names = {'file': 'FileSource', 'library': 'CompiledSource',
11✔
358
                     'parameters': 'CompiledSource'}
359
        if name in cls_names:
11✔
360
            raise AttributeError(
11✔
361
                f'The "{name}" attribute has been deprecated on the '
362
                f'IndependentSource class. Please use the {cls_names[name]} class.')
363
        else:
364
            super().__getattribute__(name)
11✔
365

366
    def __setattr__(self, name, value):
11✔
367
        if name in ('file', 'library', 'parameters'):
11✔
368
            # Ensure proper AttributeError is thrown
369
            getattr(self, name)
11✔
370
        else:
371
            super().__setattr__(name, value)
11✔
372

373
    @property
11✔
374
    def space(self):
11✔
375
        return self._space
11✔
376

377
    @space.setter
11✔
378
    def space(self, space):
11✔
379
        cv.check_type('spatial distribution', space, Spatial)
11✔
380
        self._space = space
11✔
381

382
    @property
11✔
383
    def angle(self):
11✔
384
        return self._angle
11✔
385

386
    @angle.setter
11✔
387
    def angle(self, angle):
11✔
388
        cv.check_type('angular distribution', angle, UnitSphere)
11✔
389
        self._angle = angle
11✔
390

391
    @property
11✔
392
    def energy(self):
11✔
393
        return self._energy
11✔
394

395
    @energy.setter
11✔
396
    def energy(self, energy):
11✔
397
        cv.check_type('energy distribution', energy, Univariate)
11✔
398
        self._energy = energy
11✔
399

400
    @property
11✔
401
    def time(self):
11✔
402
        return self._time
11✔
403

404
    @time.setter
11✔
405
    def time(self, time):
11✔
406
        cv.check_type('time distribution', time, Univariate)
11✔
407
        self._time = time
11✔
408

409
    @property
11✔
410
    def particle(self) -> ParticleType:
11✔
411
        return self._particle
11✔
412

413
    @particle.setter
11✔
414
    def particle(self, particle):
11✔
415
        self._particle = ParticleType(particle)
11✔
416

417
    def populate_xml_element(self, element):
11✔
418
        """Add necessary source information to an XML element
419

420
        Returns
421
        -------
422
        element : lxml.etree._Element
423
            XML element containing source data
424

425
        """
426
        element.set("particle", str(self.particle))
11✔
427
        if self.space is not None:
11✔
428
            element.append(self.space.to_xml_element())
11✔
429
        if self.angle is not None:
11✔
430
            element.append(self.angle.to_xml_element())
11✔
431
        if self.energy is not None:
11✔
432
            element.append(self.energy.to_xml_element('energy'))
11✔
433
        if self.time is not None:
11✔
434
            element.append(self.time.to_xml_element('time'))
11✔
435

436
    @classmethod
11✔
437
    def from_xml_element(cls, elem: ET.Element, meshes=None) -> SourceBase:
11✔
438
        """Generate source from an XML element
439

440
        Parameters
441
        ----------
442
        elem : lxml.etree._Element
443
            XML element
444
        meshes : dict
445
            Dictionary with mesh IDs as keys and openmc.MeshBase instaces as
446
            values
447

448
        Returns
449
        -------
450
        openmc.Source
451
            Source generated from XML element
452

453
        """
454
        constraints = cls._get_constraints(elem)
11✔
455
        source = cls(constraints=constraints)
11✔
456

457
        strength = get_text(elem, 'strength')
11✔
458
        if strength is not None:
11✔
459
            source.strength = float(strength)
11✔
460

461
        particle = get_text(elem, 'particle')
11✔
462
        if particle is not None:
11✔
463
            source.particle = particle
11✔
464

465
        space = elem.find('space')
11✔
466
        if space is not None:
11✔
467
            source.space = Spatial.from_xml_element(space, meshes)
11✔
468

469
        angle = elem.find('angle')
11✔
470
        if angle is not None:
11✔
471
            source.angle = UnitSphere.from_xml_element(angle)
11✔
472

473
        energy = elem.find('energy')
11✔
474
        if energy is not None:
11✔
475
            source.energy = Univariate.from_xml_element(energy)
11✔
476

477
        time = elem.find('time')
11✔
478
        if time is not None:
11✔
UNCOV
479
            source.time = Univariate.from_xml_element(time)
×
480

481
        return source
11✔
482

483

484
class MeshSource(SourceBase):
11✔
485
    """A source with a spatial distribution over mesh elements
486

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

494
    .. versionadded:: 0.15.0
495

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

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

535
    """
536

537
    def __init__(
11✔
538
            self,
539
            mesh: MeshBase,
540
            sources: Sequence[SourceBase],
541
            constraints: dict[str, Any] | None = None,
542
    ):
543
        super().__init__(strength=None, constraints=constraints)
11✔
544
        self.mesh = mesh
11✔
545
        self.sources = sources
11✔
546

547
    @property
11✔
548
    def type(self) -> str:
11✔
549
        return "mesh"
11✔
550

551
    @property
11✔
552
    def mesh(self) -> MeshBase:
11✔
553
        return self._mesh
11✔
554

555
    @property
11✔
556
    def strength(self) -> float:
11✔
557
        return sum(s.strength for s in self.sources)
11✔
558

559
    @property
11✔
560
    def sources(self) -> np.ndarray:
11✔
561
        return self._sources
11✔
562

563
    @mesh.setter
11✔
564
    def mesh(self, m):
11✔
565
        cv.check_type('source mesh', m, MeshBase)
11✔
566
        self._mesh = m
11✔
567

568
    @sources.setter
11✔
569
    def sources(self, s):
11✔
570
        cv.check_iterable_type('mesh sources', s, SourceBase, max_depth=3)
11✔
571

572
        s = np.asarray(s)
11✔
573

574
        if isinstance(self.mesh, StructuredMesh):
11✔
575
            if s.size != self.mesh.n_elements:
11✔
UNCOV
576
                raise ValueError(
×
577
                    f'The length of the source array ({s.size}) does not match '
578
                    f'the number of mesh elements ({self.mesh.n_elements}).')
579

580
            # If user gave a multidimensional array, flatten in the order
581
            # of the mesh indices
582
            if s.ndim > 1:
11✔
583
                s = s.ravel(order='F')
11✔
584

585
        elif isinstance(self.mesh, UnstructuredMesh):
3✔
586
            if s.ndim > 1:
3✔
UNCOV
587
                raise ValueError(
×
588
                    'Sources must be a 1-D array for unstructured mesh')
589

590
        self._sources = s
11✔
591
        for src in self._sources:
11✔
592
            if isinstance(src, IndependentSource) and src.space is not None:
11✔
593
                warnings.warn('Some sources on the mesh have spatial '
×
594
                              'distributions that will be ignored at runtime.')
UNCOV
595
                break
×
596

597
    @strength.setter
11✔
598
    def strength(self, val):
11✔
599
        if val is not None:
11✔
600
            cv.check_type('mesh source strength', val, Real)
11✔
601
            self.set_total_strength(val)
11✔
602

603
    def set_total_strength(self, strength: float):
11✔
604
        """Scales the element source strengths based on a desired total strength.
605

606
        Parameters
607
        ----------
608
        strength : float
609
            Total source strength
610

611
        """
612
        current_strength = self.strength if self.strength != 0.0 else 1.0
11✔
613

614
        for s in self.sources:
11✔
615
            s.strength *= strength / current_strength
11✔
616

617
    def normalize_source_strengths(self):
11✔
618
        """Update all element source strengths such that they sum to 1.0."""
619
        self.set_total_strength(1.0)
11✔
620

621
    def populate_xml_element(self, elem: ET.Element):
11✔
622
        """Add necessary source information to an XML element
623

624
        Returns
625
        -------
626
        element : lxml.etree._Element
627
            XML element containing source data
628

629
        """
630
        elem.set("mesh", str(self.mesh.id))
11✔
631

632
        # write in the order of mesh indices
633
        for s in self.sources:
11✔
634
            elem.append(s.to_xml_element())
11✔
635

636
    @classmethod
11✔
637
    def from_xml_element(cls, elem: ET.Element, meshes) -> openmc.MeshSource:
11✔
638
        """
639
        Generate MeshSource from an XML element
640

641
        Parameters
642
        ----------
643
        elem : lxml.etree._Element
644
            XML element
645
        meshes : dict
646
            A dictionary with mesh IDs as keys and openmc.MeshBase instances as
647
            values
648

649
        Returns
650
        -------
651
        openmc.MeshSource
652
            MeshSource generated from the XML element
653
        """
654
        mesh_id = int(get_text(elem, 'mesh'))
11✔
655
        mesh = meshes[mesh_id]
11✔
656

657
        sources = [SourceBase.from_xml_element(
11✔
658
            e) for e in elem.iterchildren('source')]
659
        constraints = cls._get_constraints(elem)
11✔
660
        return cls(mesh, sources, constraints=constraints)
11✔
661

662

663
def Source(*args, **kwargs):
11✔
664
    """
665
    A function for backward compatibility of sources. Will be removed in the
666
    future. Please update to IndependentSource.
667
    """
668
    warnings.warn(
11✔
669
        "This class is deprecated in favor of 'IndependentSource'", FutureWarning)
670
    return openmc.IndependentSource(*args, **kwargs)
11✔
671

672

673
class CompiledSource(SourceBase):
11✔
674
    """A source based on a compiled shared library
675

676
    .. versionadded:: 0.14.0
677

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

700
    Attributes
701
    ----------
702
    library : pathlib.Path
703
        Path to a compiled shared library
704
    parameters : str
705
        Parameters to be provided to the compiled shared library function
706
    strength : float
707
        Strength of the source
708
    type : str
709
        Indicator of source type: 'compiled'
710
    constraints : dict
711
        Constraints on sampled source particles. Valid keys include
712
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
713
        'fissionable', and 'rejection_strategy'.
714

715
    """
716

717
    def __init__(
11✔
718
        self,
719
        library: PathLike,
720
        parameters: str | None = None,
721
        strength: float = 1.0,
722
        constraints: dict[str, Any] | None = None
723
    ) -> None:
724
        super().__init__(strength=strength, constraints=constraints)
11✔
725
        self.library = library
11✔
726
        self._parameters = None
11✔
727
        if parameters is not None:
11✔
UNCOV
728
            self.parameters = parameters
×
729

730
    @property
11✔
731
    def type(self) -> str:
11✔
732
        return "compiled"
11✔
733

734
    @property
11✔
735
    def library(self) -> Path:
11✔
736
        return self._library
11✔
737

738
    @library.setter
11✔
739
    def library(self, library_name: PathLike):
11✔
740
        cv.check_type('library', library_name, PathLike)
11✔
741
        self._library = input_path(library_name)
11✔
742

743
    @property
11✔
744
    def parameters(self) -> str:
11✔
745
        return self._parameters
11✔
746

747
    @parameters.setter
11✔
748
    def parameters(self, parameters_path):
11✔
749
        cv.check_type('parameters', parameters_path, str)
11✔
750
        self._parameters = parameters_path
11✔
751

752
    def populate_xml_element(self, element):
11✔
753
        """Add necessary compiled source information to an XML element
754

755
        Returns
756
        -------
757
        element : lxml.etree._Element
758
            XML element containing source data
759

760
        """
761
        element.set("library", str(self.library))
11✔
762

763
        if self.parameters is not None:
11✔
764
            element.set("parameters", self.parameters)
11✔
765

766
    @classmethod
11✔
767
    def from_xml_element(cls, elem: ET.Element) -> openmc.CompiledSource:
11✔
768
        """Generate a compiled source from an XML element
769

770
        Parameters
771
        ----------
772
        elem : lxml.etree._Element
773
            XML element
774
        meshes : dict
775
            Dictionary with mesh IDs as keys and openmc.MeshBase instances as
776
            values
777

778
        Returns
779
        -------
780
        openmc.CompiledSource
781
            Source generated from XML element
782

783
        """
UNCOV
784
        kwargs = {'constraints': cls._get_constraints(elem)}
×
785
        kwargs['library'] = get_text(elem, 'library')
×
786

787
        source = cls(**kwargs)
×
788

789
        strength = get_text(elem, 'strength')
×
UNCOV
790
        if strength is not None:
×
791
            source.strength = float(strength)
×
792

793
        parameters = get_text(elem, 'parameters')
×
UNCOV
794
        if parameters is not None:
×
795
            source.parameters = parameters
×
796

UNCOV
797
        return source
×
798

799

800
class FileSource(SourceBase):
11✔
801
    """A source based on particles stored in a file
802

803
    .. versionadded:: 0.14.0
804

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

825
    Attributes
826
    ----------
827
    path : Pathlike
828
        Source file from which sites should be sampled
829
    strength : float
830
        Strength of the source
831
    type : str
832
        Indicator of source type: 'file'
833
    constraints : dict
834
        Constraints on sampled source particles. Valid keys include
835
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
836
        'fissionable', and 'rejection_strategy'.
837

838
    """
839

840
    def __init__(
11✔
841
        self,
842
        path: PathLike,
843
        strength: float = 1.0,
844
        constraints: dict[str, Any] | None = None
845
    ):
846
        super().__init__(strength=strength, constraints=constraints)
11✔
847
        self.path = path
11✔
848

849
    @property
11✔
850
    def type(self) -> str:
11✔
851
        return "file"
11✔
852

853
    @property
11✔
854
    def path(self) -> PathLike:
11✔
855
        return self._path
11✔
856

857
    @path.setter
11✔
858
    def path(self, p: PathLike):
11✔
859
        cv.check_type('source file', p, PathLike)
11✔
860
        self._path = input_path(p)
11✔
861

862
    def populate_xml_element(self, element):
11✔
863
        """Add necessary file source information to an XML element
864

865
        Returns
866
        -------
867
        element : lxml.etree._Element
868
            XML element containing source data
869

870
        """
871
        if self.path is not None:
11✔
872
            element.set("file", str(self.path))
11✔
873

874
    @classmethod
11✔
875
    def from_xml_element(cls, elem: ET.Element) -> openmc.FileSource:
11✔
876
        """Generate file source from an XML element
877

878
        Parameters
879
        ----------
880
        elem : lxml.etree._Element
881
            XML element
882
        meshes : dict
883
            Dictionary with mesh IDs as keys and openmc.MeshBase instances as
884
            values
885

886
        Returns
887
        -------
888
        openmc.FileSource
889
            Source generated from XML element
890

891
        """
892
        kwargs = {'constraints': cls._get_constraints(elem)}
×
893
        kwargs['path'] = get_text(elem, 'file')
×
894
        strength = get_text(elem, 'strength')
×
UNCOV
895
        if strength is not None:
×
896
            kwargs['strength'] = float(strength)
×
897

UNCOV
898
        return cls(**kwargs)
×
899

900

901
class TokamakSource(SourceBase):
11✔
902
    r"""A source representing neutron emission from a tokamak plasma.
903

904
    This source samples neutron positions from a tokamak plasma geometry using
905
    Miller-style flux surface parameterization. The user provides an emission
906
    profile S(r/a) as a function of normalized minor radius, along with one or
907
    more energy distributions.
908

909
    The flux surface parameterization is
910

911
    .. math::
912

913
        \begin{aligned}
914
        R &= R_0 + r \cos\left(\alpha + \delta \sin\alpha\right)
915
             + \Delta \left[1 - \left(\frac{r}{a}\right)^2\right] \\
916
        Z &= Z_\mathrm{shift} + \kappa r \sin\alpha
917
        \end{aligned}
918

919
    where :math:`R_0` is major radius, :math:`a` is minor radius,
920
    :math:`\kappa` is elongation, :math:`\delta` is triangularity,
921
    :math:`\Delta` is the Shafranov shift, and :math:`Z_\mathrm{shift}` is
922
    the vertical shift.
923

924
    .. versionadded:: 0.15.4
925

926
    Parameters
927
    ----------
928
    major_radius : float
929
        Major radius R0 in [cm]
930
    minor_radius : float
931
        Minor radius a in [cm]
932
    elongation : float
933
        Plasma elongation κ (must be > 0)
934
    triangularity : float
935
        Plasma triangularity δ (must be in [-1, 1])
936
    shafranov_shift : float
937
        Shafranov shift Δ in [cm] (must be >= 0 and < a/2)
938
    r_over_a : numpy.ndarray
939
        Normalized minor radius grid points, must start at 0 and end at 1
940
    emission_density : numpy.ndarray
941
        Emission density S(r) at each r/a point (arbitrary units, must be >= 0).
942
        Values are linearly interpolated between grid points and refined on an
943
        internal grid for radial sampling. Must have the same length as
944
        ``r_over_a`` and contain at least one positive value.
945
    energy : openmc.stats.Univariate or Sequence[openmc.stats.Univariate]
946
        Energy distribution(s). Either a single distribution used at all radii,
947
        or one distribution per ``r_over_a`` grid point. When one distribution
948
        per grid point is given, the energy of a sampled particle is drawn from
949
        one of the two distributions bracketing its sampled radius, selected
950
        stochastically with probability proportional to the proximity of the
951
        radius to each grid point (stochastic interpolation).
952
    time : openmc.stats.Univariate, optional
953
        Time distribution of the source. If None, particles are born at
954
        :math:`t=0`, matching the default behavior of
955
        :class:`openmc.IndependentSource`.
956
    phi_start : float
957
        Starting toroidal angle in [rad] (default: 0)
958
    phi_extent : float
959
        Toroidal angle extent in [rad] (default: 2Ï€)
960
    n_alpha : int
961
        Number of poloidal angle grid points for CDF sampling (default: 101)
962
    vertical_shift : float
963
        Vertical shift of the plasma center in [cm] (default: 0)
964
    strength : float
965
        Strength of the source (default: 1.0)
966
    constraints : dict
967
        Constraints on sampled source particles. See :class:`SourceBase` for
968
        valid keys and values.
969

970
    Attributes
971
    ----------
972
    major_radius : float
973
        Major radius R0 in [cm]
974
    minor_radius : float
975
        Minor radius a in [cm]
976
    elongation : float
977
        Plasma elongation κ
978
    triangularity : float
979
        Plasma triangularity δ
980
    shafranov_shift : float
981
        Shafranov shift Δ in [cm]
982
    r_over_a : numpy.ndarray
983
        Normalized minor radius grid points
984
    emission_density : numpy.ndarray
985
        Emission density S(r) at each r/a point
986
    energy : list of openmc.stats.Univariate
987
        Energy distribution(s)
988
    time : openmc.stats.Univariate or None
989
        Time distribution of the source
990
    phi_start : float
991
        Starting toroidal angle in [rad]
992
    phi_extent : float
993
        Toroidal angle extent in [rad]
994
    n_alpha : int
995
        Number of poloidal angle grid points
996
    vertical_shift : float
997
        Vertical shift of the plasma center in [cm]
998
    strength : float
999
        Strength of the source
1000
    type : str
1001
        Indicator of source type: 'tokamak'
1002
    constraints : dict
1003
        Constraints on sampled source particles
1004

1005
    """
1006

1007
    def __init__(
11✔
1008
        self,
1009
        major_radius: float,
1010
        minor_radius: float,
1011
        elongation: float,
1012
        triangularity: float,
1013
        shafranov_shift: float,
1014
        r_over_a: Sequence[float],
1015
        emission_density: Sequence[float],
1016
        energy: Univariate | Sequence[Univariate],
1017
        time: Univariate | None = None,
1018
        phi_start: float = 0.0,
1019
        phi_extent: float = 2.0 * np.pi,
1020
        n_alpha: int = 101,
1021
        vertical_shift: float = 0.0,
1022
        strength: float = 1.0,
1023
        constraints: dict[str, Any] | None = None
1024
    ):
1025
        super().__init__(strength=strength, constraints=constraints)
11✔
1026
        self.major_radius = major_radius
11✔
1027
        self.minor_radius = minor_radius
11✔
1028
        self.elongation = elongation
11✔
1029
        self.triangularity = triangularity
11✔
1030
        self.shafranov_shift = shafranov_shift
11✔
1031
        self.r_over_a = r_over_a
11✔
1032
        self.emission_density = emission_density
11✔
1033
        self.phi_start = phi_start
11✔
1034
        self.phi_extent = phi_extent
11✔
1035
        self.n_alpha = n_alpha
11✔
1036
        self.vertical_shift = vertical_shift
11✔
1037
        self.energy = energy
11✔
1038
        self.time = time
11✔
1039

1040
        self._validate()
11✔
1041

1042
    def _validate(self):
11✔
1043
        """Validate relationships between tokamak source parameters."""
1044
        if self.minor_radius >= self.major_radius:
11✔
1045
            raise ValueError(
11✔
1046
                f"minor_radius ({self.minor_radius}) must be smaller than "
1047
                f"major_radius ({self.major_radius})")
1048
        if self.shafranov_shift >= 0.5 * self.minor_radius:
11✔
1049
            raise ValueError(
11✔
1050
                f"shafranov_shift ({self.shafranov_shift}) must be smaller "
1051
                f"than half the minor_radius ({0.5 * self.minor_radius})")
1052
        if len(self.emission_density) != len(self.r_over_a):
11✔
1053
            raise ValueError(
11✔
1054
                f"emission_density (length {len(self.emission_density)}) must "
1055
                f"have the same length as r_over_a (length {len(self.r_over_a)})")
1056
        if not np.any(self.emission_density > 0.0):
11✔
1057
            raise ValueError("emission_density must contain a positive value")
11✔
1058
        if len(self.energy) not in (1, len(self.r_over_a)):
11✔
1059
            raise ValueError(
11✔
1060
                f"Number of energy distributions ({len(self.energy)}) must be "
1061
                f"either 1 or equal to the number of r_over_a grid points "
1062
                f"({len(self.r_over_a)})")
1063

1064
    @property
11✔
1065
    def type(self) -> str:
11✔
1066
        return "tokamak"
11✔
1067

1068
    @property
11✔
1069
    def major_radius(self) -> float:
11✔
1070
        return self._major_radius
11✔
1071

1072
    @major_radius.setter
11✔
1073
    def major_radius(self, value: float):
11✔
1074
        cv.check_type('major radius', value, Real)
11✔
1075
        cv.check_greater_than('major radius', value, 0.0)
11✔
1076
        self._major_radius = value
11✔
1077

1078
    @property
11✔
1079
    def minor_radius(self) -> float:
11✔
1080
        return self._minor_radius
11✔
1081

1082
    @minor_radius.setter
11✔
1083
    def minor_radius(self, value: float):
11✔
1084
        cv.check_type('minor radius', value, Real)
11✔
1085
        cv.check_greater_than('minor radius', value, 0.0)
11✔
1086
        self._minor_radius = value
11✔
1087

1088
    @property
11✔
1089
    def elongation(self) -> float:
11✔
1090
        return self._elongation
11✔
1091

1092
    @elongation.setter
11✔
1093
    def elongation(self, value: float):
11✔
1094
        cv.check_type('elongation', value, Real)
11✔
1095
        cv.check_greater_than('elongation', value, 0.0)
11✔
1096
        self._elongation = value
11✔
1097

1098
    @property
11✔
1099
    def triangularity(self) -> float:
11✔
1100
        return self._triangularity
11✔
1101

1102
    @triangularity.setter
11✔
1103
    def triangularity(self, value: float):
11✔
1104
        cv.check_type('triangularity', value, Real)
11✔
1105
        cv.check_greater_than('triangularity', value, -1.0, equality=True)
11✔
1106
        cv.check_less_than('triangularity', value, 1.0, equality=True)
11✔
1107
        self._triangularity = value
11✔
1108

1109
    @property
11✔
1110
    def shafranov_shift(self) -> float:
11✔
1111
        return self._shafranov_shift
11✔
1112

1113
    @shafranov_shift.setter
11✔
1114
    def shafranov_shift(self, value: float):
11✔
1115
        cv.check_type('Shafranov shift', value, Real)
11✔
1116
        cv.check_greater_than('Shafranov shift', value, 0.0, equality=True)
11✔
1117
        self._shafranov_shift = value
11✔
1118

1119
    @property
11✔
1120
    def r_over_a(self) -> np.ndarray:
11✔
1121
        return self._r_over_a
11✔
1122

1123
    @r_over_a.setter
11✔
1124
    def r_over_a(self, value: Sequence[float]):
11✔
1125
        value = np.asarray(value, dtype=float)
11✔
1126
        if value.ndim != 1 or len(value) < 2:
11✔
UNCOV
1127
            raise ValueError("r_over_a must be a 1-D array with at least 2 points")
×
1128
        if value[0] != 0.0:
11✔
1129
            raise ValueError("r_over_a must start at 0")
11✔
1130
        if value[-1] != 1.0:
11✔
1131
            raise ValueError("r_over_a must end at 1")
11✔
1132
        if not np.all(np.diff(value) > 0):
11✔
UNCOV
1133
            raise ValueError("r_over_a must be strictly increasing")
×
1134
        self._r_over_a = value
11✔
1135

1136
    @property
11✔
1137
    def emission_density(self) -> np.ndarray:
11✔
1138
        return self._emission_density
11✔
1139

1140
    @emission_density.setter
11✔
1141
    def emission_density(self, value: Sequence[float]):
11✔
1142
        value = np.asarray(value, dtype=float)
11✔
1143
        if value.ndim != 1:
11✔
UNCOV
1144
            raise ValueError("emission_density must be a 1-D array")
×
1145
        if np.any(value < 0):
11✔
1146
            raise ValueError("emission_density values cannot be negative")
11✔
1147
        self._emission_density = value
11✔
1148

1149
    @property
11✔
1150
    def energy(self) -> list[Univariate]:
11✔
1151
        return self._energy
11✔
1152

1153
    @energy.setter
11✔
1154
    def energy(self, value: Univariate | Sequence[Univariate]):
11✔
1155
        if isinstance(value, Univariate):
11✔
1156
            self._energy = [value]
11✔
1157
        else:
1158
            cv.check_iterable_type('energy distributions', value, Univariate)
11✔
1159
            self._energy = list(value)
11✔
1160

1161
    @property
11✔
1162
    def time(self) -> Univariate | None:
11✔
1163
        return self._time
11✔
1164

1165
    @time.setter
11✔
1166
    def time(self, value: Univariate | None):
11✔
1167
        if value is not None:
11✔
1168
            cv.check_type('time distribution', value, Univariate)
11✔
1169
        self._time = value
11✔
1170

1171
    @property
11✔
1172
    def phi_start(self) -> float:
11✔
1173
        return self._phi_start
11✔
1174

1175
    @phi_start.setter
11✔
1176
    def phi_start(self, value: float):
11✔
1177
        cv.check_type('phi_start', value, Real)
11✔
1178
        self._phi_start = value
11✔
1179

1180
    @property
11✔
1181
    def phi_extent(self) -> float:
11✔
1182
        return self._phi_extent
11✔
1183

1184
    @phi_extent.setter
11✔
1185
    def phi_extent(self, value: float):
11✔
1186
        cv.check_type('phi_extent', value, Real)
11✔
1187
        cv.check_greater_than('phi_extent', value, 0.0)
11✔
1188
        cv.check_less_than('phi_extent', value, 2.0 * np.pi, equality=True)
11✔
1189
        self._phi_extent = value
11✔
1190

1191
    @property
11✔
1192
    def n_alpha(self) -> int:
11✔
1193
        return self._n_alpha
11✔
1194

1195
    @n_alpha.setter
11✔
1196
    def n_alpha(self, value: int):
11✔
1197
        cv.check_type('n_alpha', value, Integral)
11✔
1198
        cv.check_greater_than('n_alpha', value, 2)
11✔
1199
        if value < 51:
11✔
1200
            warnings.warn(
11✔
1201
                "n_alpha values below 51 may introduce noticeable "
1202
                "discretization bias in tokamak source sampling", stacklevel=2)
1203
        self._n_alpha = value
11✔
1204

1205
    @property
11✔
1206
    def vertical_shift(self) -> float:
11✔
1207
        return self._vertical_shift
11✔
1208

1209
    @vertical_shift.setter
11✔
1210
    def vertical_shift(self, value: float):
11✔
1211
        cv.check_type('vertical shift', value, Real)
11✔
1212
        self._vertical_shift = value
11✔
1213

1214
    def populate_xml_element(self, element):
11✔
1215
        """Add necessary tokamak source information to an XML element
1216

1217
        Returns
1218
        -------
1219
        element : lxml.etree._Element
1220
            XML element containing source data
1221

1222
        """
1223
        self._validate()
11✔
1224

1225
        # Geometry parameters
1226
        ET.SubElement(element, "major_radius").text = str(self.major_radius)
11✔
1227
        ET.SubElement(element, "minor_radius").text = str(self.minor_radius)
11✔
1228
        ET.SubElement(element, "elongation").text = str(self.elongation)
11✔
1229
        ET.SubElement(element, "triangularity").text = str(self.triangularity)
11✔
1230
        ET.SubElement(element, "shafranov_shift").text = str(self.shafranov_shift)
11✔
1231

1232
        # Toroidal angle bounds
1233
        ET.SubElement(element, "phi_start").text = str(self.phi_start)
11✔
1234
        ET.SubElement(element, "phi_extent").text = str(self.phi_extent)
11✔
1235

1236
        # Poloidal sampling resolution
1237
        ET.SubElement(element, "n_alpha").text = str(self.n_alpha)
11✔
1238

1239
        # Vertical shift
1240
        if self.vertical_shift != 0.0:
11✔
1241
            ET.SubElement(element, "vertical_shift").text = str(self.vertical_shift)
11✔
1242

1243
        # Emission profile
1244
        ET.SubElement(element, "r_over_a").text = ' '.join(str(r) for r in self.r_over_a)
11✔
1245
        ET.SubElement(element, "emission_density").text = ' '.join(str(s) for s in self.emission_density)
11✔
1246

1247
        # Energy distribution(s)
1248
        for dist in self.energy:
11✔
1249
            element.append(dist.to_xml_element('energy'))
11✔
1250

1251
        # Time distribution
1252
        if self.time is not None:
11✔
1253
            element.append(self.time.to_xml_element('time'))
11✔
1254

1255
    @classmethod
11✔
1256
    def from_xml_element(cls, elem: ET.Element) -> TokamakSource:
11✔
1257
        """Generate tokamak source from an XML element
1258

1259
        Parameters
1260
        ----------
1261
        elem : lxml.etree._Element
1262
            XML element
1263

1264
        Returns
1265
        -------
1266
        openmc.TokamakSource
1267
            Source generated from XML element
1268

1269
        """
1270
        # Read geometry parameters
1271
        major_radius = float(get_text(elem, 'major_radius'))
11✔
1272
        minor_radius = float(get_text(elem, 'minor_radius'))
11✔
1273
        elongation = float(get_text(elem, 'elongation'))
11✔
1274
        triangularity = float(get_text(elem, 'triangularity'))
11✔
1275
        shafranov_shift = float(get_text(elem, 'shafranov_shift'))
11✔
1276

1277
        # Read optional parameters
1278
        phi_start_text = get_text(elem, 'phi_start')
11✔
1279
        phi_start = float(phi_start_text) if phi_start_text else 0.0
11✔
1280

1281
        phi_extent_text = get_text(elem, 'phi_extent')
11✔
1282
        phi_extent = float(phi_extent_text) if phi_extent_text else 2.0 * np.pi
11✔
1283

1284
        n_alpha_text = get_text(elem, 'n_alpha')
11✔
1285
        n_alpha = int(n_alpha_text) if n_alpha_text else 101
11✔
1286

1287
        vertical_shift_text = get_text(elem, 'vertical_shift')
11✔
1288
        vertical_shift = float(vertical_shift_text) if vertical_shift_text else 0.0
11✔
1289

1290
        # Read emission profile
1291
        r_over_a = np.array([float(x) for x in get_text(elem, 'r_over_a').split()])
11✔
1292
        emission_density = np.array([float(x) for x in get_text(elem, 'emission_density').split()])
11✔
1293

1294
        # Read energy distributions
1295
        energy = [Univariate.from_xml_element(e) for e in elem.findall('energy')]
11✔
1296
        if len(energy) == 1:
11✔
1297
            energy = energy[0]
11✔
1298

1299
        # Read time distribution
1300
        time_elem = elem.find('time')
11✔
1301
        time = Univariate.from_xml_element(time_elem) if time_elem is not None else None
11✔
1302

1303
        # Read constraints and strength
1304
        constraints = cls._get_constraints(elem)
11✔
1305
        strength_text = get_text(elem, 'strength')
11✔
1306
        strength = float(strength_text) if strength_text else 1.0
11✔
1307

1308
        return cls(
11✔
1309
            major_radius=major_radius,
1310
            minor_radius=minor_radius,
1311
            elongation=elongation,
1312
            triangularity=triangularity,
1313
            shafranov_shift=shafranov_shift,
1314
            r_over_a=r_over_a,
1315
            emission_density=emission_density,
1316
            energy=energy,
1317
            time=time,
1318
            phi_start=phi_start,
1319
            phi_extent=phi_extent,
1320
            n_alpha=n_alpha,
1321
            vertical_shift=vertical_shift,
1322
            strength=strength,
1323
            constraints=constraints
1324
        )
1325

1326

1327
class SourceParticle:
11✔
1328
    """Source particle
1329

1330
    This class can be used to create source particles that can be written to a
1331
    file and used by OpenMC
1332

1333
    Parameters
1334
    ----------
1335
    r : iterable of float
1336
        Position of particle in Cartesian coordinates
1337
    u : iterable of float
1338
        Directional cosines
1339
    E : float
1340
        Energy of particle in [eV]
1341
    time : float
1342
        Time of particle in [s]
1343
    wgt : float
1344
        Weight of the particle
1345
    delayed_group : int
1346
        Delayed group particle was created in (neutrons only)
1347
    surf_id : int
1348
        Surface ID where particle is at, if any.
1349
    particle : ParticleType or str or int
1350
        Type of the particle (type, name, or PDG number)
1351

1352
    """
1353

1354
    def __init__(
11✔
1355
        self,
1356
        r: Iterable[float] = (0., 0., 0.),
1357
        u: Iterable[float] = (0., 0., 1.),
1358
        E: float = 1.0e6,
1359
        time: float = 0.0,
1360
        wgt: float = 1.0,
1361
        delayed_group: int = 0,
1362
        surf_id: int = 0,
1363
        particle: ParticleType | str | int = ParticleType.NEUTRON
1364
    ):
1365

1366
        self.r = tuple(r)
11✔
1367
        self.u = tuple(u)
11✔
1368
        self.E = float(E)
11✔
1369
        self.time = float(time)
11✔
1370
        self.wgt = float(wgt)
11✔
1371
        self.delayed_group = delayed_group
11✔
1372
        self.surf_id = surf_id
11✔
1373
        self.particle = particle
11✔
1374

1375
    @property
11✔
1376
    def particle(self) -> ParticleType:
11✔
1377
        return self._particle
11✔
1378

1379
    @particle.setter
11✔
1380
    def particle(self, particle):
11✔
1381
        self._particle = ParticleType(particle)
11✔
1382

1383
    def __repr__(self):
11✔
UNCOV
1384
        return f'<SourceParticle: {str(self.particle)} at E={self.E:.6e} eV>'
×
1385

1386
    def to_tuple(self) -> tuple:
11✔
1387
        """Return source particle attributes as a tuple
1388

1389
        Returns
1390
        -------
1391
        tuple
1392
            Source particle attributes
1393

1394
        """
1395
        return (self.r, self.u, self.E, self.time, self.wgt,
11✔
1396
                self.delayed_group, self.surf_id, self.particle.pdg_number)
1397

1398

1399
def write_source_file(
11✔
1400
    source_particles: Iterable[SourceParticle],
1401
    filename: PathLike, **kwargs
1402
):
1403
    """Write a source file using a collection of source particles
1404

1405
    Parameters
1406
    ----------
1407
    source_particles : iterable of SourceParticle
1408
        Source particles to write to file
1409
    filename : str or path-like
1410
        Path to source file to write
1411
    **kwargs
1412
        Keyword arguments to pass to :class:`h5py.File`
1413

1414
    See Also
1415
    --------
1416
    openmc.SourceParticle
1417

1418
    """
1419
    cv.check_iterable_type(
11✔
1420
        "source particles", source_particles, SourceParticle)
1421
    pl = ParticleList(source_particles)
11✔
1422
    pl.export_to_hdf5(filename, **kwargs)
11✔
1423

1424

1425
class ParticleList(list):
11✔
1426
    """A collection of SourceParticle objects.
1427

1428
    Parameters
1429
    ----------
1430
    particles : list of SourceParticle
1431
        Particles to collect into the list
1432

1433
    """
1434
    @classmethod
11✔
1435
    def from_hdf5(cls, filename: PathLike) -> ParticleList:
11✔
1436
        """Create particle list from an HDF5 file.
1437

1438
        Parameters
1439
        ----------
1440
        filename : path-like
1441
            Path to source file to read.
1442

1443
        Returns
1444
        -------
1445
        ParticleList instance
1446

1447
        """
1448
        with h5py.File(filename, 'r') as fh:
11✔
1449
            filetype = fh.attrs['filetype']
11✔
1450
            arr = fh['source_bank'][...]
11✔
1451

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

1455
        source_particles = [
11✔
1456
            SourceParticle(*params, ParticleType(particle))
1457
            for *params, particle in arr
1458
        ]
1459
        return cls(source_particles)
11✔
1460

1461
    @classmethod
11✔
1462
    def from_mcpl(cls, filename: PathLike) -> ParticleList:
11✔
1463
        """Create particle list from an MCPL file.
1464

1465
        Parameters
1466
        ----------
1467
        filename : path-like
1468
            Path to MCPL file to read.
1469

1470
        Returns
1471
        -------
1472
        ParticleList instance
1473

1474
        """
UNCOV
1475
        import mcpl
×
1476
        # Process .mcpl file
UNCOV
1477
        particles = []
×
UNCOV
1478
        with mcpl.MCPLFile(filename) as f:
×
UNCOV
1479
            for particle in f.particles:
×
UNCOV
1480
                particle_type = ParticleType(particle.pdgcode)
×
1481

1482
                # Create a source particle instance. Note that MCPL stores
1483
                # energy in MeV and time in ms.
UNCOV
1484
                source_particle = SourceParticle(
×
1485
                    r=tuple(particle.position),
1486
                    u=tuple(particle.direction),
1487
                    E=1.0e6*particle.ekin,
1488
                    time=1.0e-3*particle.time,
1489
                    wgt=particle.weight,
1490
                    particle=particle_type
1491
                )
UNCOV
1492
                particles.append(source_particle)
×
1493

UNCOV
1494
        return cls(particles)
×
1495

1496
    def __getitem__(self, index):
11✔
1497
        """
1498
        Return a new ParticleList object containing the particle(s)
1499
        at the specified index or slice.
1500

1501
        Parameters
1502
        ----------
1503
        index : int, slice or list
1504
            The index, slice or list to select from the list of particles
1505

1506
        Returns
1507
        -------
1508
        openmc.ParticleList or openmc.SourceParticle
1509
            A new object with the selected particle(s)
1510
        """
1511
        if isinstance(index, int):
11✔
1512
            # If it's a single integer, return the corresponding particle
1513
            return super().__getitem__(index)
11✔
1514
        elif isinstance(index, slice):
11✔
1515
            # If it's a slice, return a new ParticleList object with the
1516
            # sliced particles
1517
            return ParticleList(super().__getitem__(index))
11✔
1518
        elif isinstance(index, list):
11✔
1519
            # If it's a list of integers, return a new ParticleList object with
1520
            # the selected particles. Note that Python 3.10 gets confused if you
1521
            # use super() here, so we call list.__getitem__ directly.
1522
            return ParticleList([list.__getitem__(self, i) for i in index])
11✔
1523
        else:
UNCOV
1524
            raise TypeError(f"Invalid index type: {type(index)}. Must be int, "
×
1525
                            "slice, or list of int.")
1526

1527
    def to_dataframe(self) -> pd.DataFrame:
11✔
1528
        """A dataframe representing the source particles
1529

1530
        Returns
1531
        -------
1532
        pandas.DataFrame
1533
            DataFrame containing the source particles attributes.
1534
        """
1535
        # Extract the attributes of the source particles into a list of tuples
1536
        data = [(sp.r[0], sp.r[1], sp.r[2], sp.u[0], sp.u[1], sp.u[2],
11✔
1537
                 sp.E, sp.time, sp.wgt, sp.delayed_group, sp.surf_id,
1538
                 str(sp.particle)) for sp in self]
1539

1540
        # Define the column names for the DataFrame
1541
        columns = ['x', 'y', 'z', 'u_x', 'u_y', 'u_z', 'E', 'time', 'wgt',
11✔
1542
                   'delayed_group', 'surf_id', 'particle']
1543

1544
        # Create the pandas DataFrame from the data
1545
        return pd.DataFrame(data, columns=columns)
11✔
1546

1547
    def export_to_hdf5(self, filename: PathLike, **kwargs):
11✔
1548
        """Export particle list to an HDF5 file.
1549

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

1553
        Parameters
1554
        ----------
1555
        filename : path-like
1556
            Path to source file to write
1557
        **kwargs
1558
            Keyword arguments to pass to :class:`h5py.File`
1559

1560
        See Also
1561
        --------
1562
        openmc.FileSource
1563

1564
        """
1565
        # Create compound datatype for source particles
1566
        pos_dtype = np.dtype([('x', '<f8'), ('y', '<f8'), ('z', '<f8')])
11✔
1567
        source_dtype = np.dtype([
11✔
1568
            ('r', pos_dtype),
1569
            ('u', pos_dtype),
1570
            ('E', '<f8'),
1571
            ('time', '<f8'),
1572
            ('wgt', '<f8'),
1573
            ('delayed_group', '<i4'),
1574
            ('surf_id', '<i4'),
1575
            ('particle', '<i4'),
1576
        ])
1577

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

1581
        # Write array to file
1582
        kwargs.setdefault('mode', 'w')
11✔
1583
        with h5py.File(filename, **kwargs) as fh:
11✔
1584
            fh.attrs['filetype'] = np.bytes_("source")
11✔
1585
            fh.attrs['version'] = np.array([_VERSION_STATEPOINT, 2])
11✔
1586
            fh.create_dataset('source_bank', data=arr, dtype=source_dtype)
11✔
1587

1588

1589
def read_source_file(filename: PathLike) -> ParticleList:
11✔
1590
    """Read a source file and return a list of source particles.
1591

1592
    .. versionadded:: 0.15.0
1593

1594
    Parameters
1595
    ----------
1596
    filename : str or path-like
1597
        Path to source file to read
1598

1599
    Returns
1600
    -------
1601
    openmc.ParticleList
1602

1603
    See Also
1604
    --------
1605
    openmc.SourceParticle
1606

1607
    """
1608
    filename = Path(filename)
11✔
1609
    if filename.suffix not in ('.h5', '.mcpl'):
11✔
UNCOV
1610
        raise ValueError('Source file must have a .h5 or .mcpl extension.')
×
1611

1612
    if filename.suffix == '.h5':
11✔
1613
        return ParticleList.from_hdf5(filename)
11✔
1614
    else:
UNCOV
1615
        return ParticleList.from_mcpl(filename)
×
1616

1617

1618
def read_collision_track_hdf5(filename):
11✔
1619
    """Read a collision track file in HDF5 format.
1620

1621
    Parameters
1622
    ----------
1623
    filename : str or path-like
1624
        Path to the HDF5 collision track file.
1625

1626
    Returns
1627
    -------
1628
    numpy.ndarray
1629
        Structured array containing collision track data.
1630

1631
    See Also
1632
    --------
1633
    read_collision_track_mcpl
1634
    read_collision_track_file
1635
    """
1636

1637
    with h5py.File(filename, 'r') as file:
11✔
1638
        data = file['collision_track_bank'][:]
11✔
1639

1640
    return data
11✔
1641

1642

1643
def read_collision_track_mcpl(file_path):
11✔
1644
    """Read a collision track file in MCPL format.
1645

1646
    Parameters
1647
    ----------
1648
    file_path : str or path-like
1649
        Path to the MCPL collision track file.
1650

1651
    Returns
1652
    -------
1653
    numpy.ndarray
1654
        Structured array of particle collision track information, including
1655
        position, direction, energy, weight, reaction data, and identifiers.
1656

1657
    See Also
1658
    --------
1659
    read_collision_track_hdf5
1660
    read_collision_track_file
1661
    """
1662
    import mcpl
11✔
1663
    myfile = mcpl.MCPLFile(file_path)
11✔
1664
    data = {
11✔
1665
        'r': [],  # for position (x, y, z)
1666
        'u': [],  # for direction (ux, uy, uz)
1667
        'E': [], 'dE': [], 'time': [],
1668
        'wgt': [], 'event_mt': [], 'delayed_group': [],
1669
        'cell_id': [], 'nuclide_id': [], 'material_id': [],
1670
        'universe_id': [], 'n_collision': [], 'particle': [],
1671
        'parent_id': [], 'progeny_id': []
1672
    }
1673

1674
    # Read and collect data from the MCPL file
1675
    for i, p in enumerate(myfile.particles):
11✔
1676
        if f'blob_{i}' in myfile.blobs:
11✔
1677
            blob_data = myfile.blobs[f'blob_{i}']
11✔
1678
            decoded_str = blob_data.decode('utf-8')
11✔
1679
            pairs = decoded_str.split(';')
11✔
1680
            values_dict = {k.strip(): v.strip()
11✔
1681
                           for k, v in (pair.split(':') for pair in pairs if pair.strip())}
1682

1683
            data['r'].append((p.x, p.y, p.z))  # Append as tuple
11✔
1684
            data['u'].append((p.ux, p.uy, p.uz))  # Append as tuple
11✔
1685
            data['E'].append(p.ekin * 1e6)
11✔
1686
            data['dE'].append(float(values_dict.get('dE', 0)))
11✔
1687
            data['time'].append(p.time * 1e-3)
11✔
1688
            data['wgt'].append(p.weight)
11✔
1689
            data['event_mt'].append(int(values_dict.get('event_mt', 0)))
11✔
1690
            data['delayed_group'].append(
11✔
1691
                int(values_dict.get('delayed_group', 0)))
1692
            data['cell_id'].append(int(values_dict.get('cell_id', 0)))
11✔
1693
            data['nuclide_id'].append(int(values_dict.get('nuclide_id', 0)))
11✔
1694
            data['material_id'].append(int(values_dict.get('material_id', 0)))
11✔
1695
            data['universe_id'].append(int(values_dict.get('universe_id', 0)))
11✔
1696
            data['n_collision'].append(int(values_dict.get('n_collision', 0)))
11✔
1697
            data['particle'].append(ParticleType(p.pdgcode))
11✔
1698
            data['parent_id'].append(int(values_dict.get('parent_id', 0)))
11✔
1699
            data['progeny_id'].append(int(values_dict.get('progeny_id', 0)))
11✔
1700

1701
    dtypes = [
11✔
1702
        ('r', [('x', 'f8'), ('y', 'f8'), ('z', 'f8')]),
1703
        ('u', [('x', 'f8'), ('y', 'f8'), ('z', 'f8')]),
1704
        ('E', 'f8'), ('dE', 'f8'), ('time', 'f8'), ('wgt', 'f8'),
1705
        ('event_mt', 'f8'), ('delayed_group', 'i4'), ('cell_id', 'i4'),
1706
        ('nuclide_id', 'i4'), ('material_id', 'i4'), ('universe_id', 'i4'),
1707
        ('n_collision', 'i4'), ('particle', 'i4'),
1708
        ('parent_id', 'i8'), ('progeny_id', 'i8')
1709
    ]
1710

1711
    structured_array = np.zeros(len(data['r']), dtype=dtypes)
11✔
1712
    for key in data:
11✔
1713
        structured_array[key] = data[key]  # Assign data
11✔
1714

1715
    return structured_array
11✔
1716

1717

1718
def read_collision_track_file(filename):
11✔
1719
    """Read a collision track file (HDF5 or MCPL) and return its data.
1720

1721
    Parameters
1722
    ----------
1723
    filename : str or path-like
1724
        Path to the collision track file to read. Must end with
1725
        ``.h5`` or ``.mcpl``.
1726

1727
    Returns
1728
    -------
1729
    numpy.ndarray
1730
        Structured array containing collision track data.
1731

1732
    See Also
1733
    --------
1734
    read_collision_track_hdf5
1735
    read_collision_track_mcpl
1736
    """
1737

1738
    filename = Path(filename)
11✔
1739
    if filename.suffix not in ('.h5', '.mcpl'):
11✔
UNCOV
1740
        raise ValueError('Collision track file must have a .h5 or .mcpl extension.')
×
1741

1742
    if filename.suffix == '.h5':
11✔
1743
        return read_collision_track_hdf5(filename)
11✔
1744
    else:
1745
        return read_collision_track_mcpl(filename)
11✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc