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

openmc-dev / openmc / 18537555145

15 Oct 2025 05:37PM UTC coverage: 81.983% (-3.2%) from 85.194%
18537555145

Pull #3417

github

web-flow
Merge 3615a1fcc into e9077b137
Pull Request #3417: Addition of a collision tracking feature

16802 of 23354 branches covered (71.94%)

Branch coverage included in aggregate %.

480 of 522 new or added lines in 13 files covered. (91.95%)

483 existing lines in 53 files now uncovered.

54134 of 63171 relevant lines covered (85.69%)

43199115.04 hits per line

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

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

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

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

25

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

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

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

58
    """
59

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

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

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

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

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

89
        for key, value in constraints.items():
11✔
90
            if key == 'domains':
11✔
91
                cv.check_type('domains', value, Iterable,
11✔
92
                              (openmc.Cell, openmc.Material, openmc.Universe))
93
                if isinstance(value[0], openmc.Cell):
11✔
94
                    self._constraints['domain_type'] = 'cell'
11✔
95
                elif isinstance(value[0], openmc.Material):
11✔
96
                    self._constraints['domain_type'] = 'material'
×
97
                elif isinstance(value[0], openmc.Universe):
11✔
98
                    self._constraints['domain_type'] = 'universe'
11✔
99
                self._constraints['domain_ids'] = [d.id for d in value]
11✔
100
            elif key == 'time_bounds':
11✔
101
                cv.check_type('time bounds', value, Iterable, Real)
11✔
102
                self._constraints['time_bounds'] = tuple(value)
11✔
103
            elif key == 'energy_bounds':
11✔
104
                cv.check_type('energy bounds', value, Iterable, Real)
11✔
105
                self._constraints['energy_bounds'] = tuple(value)
11✔
106
            elif key == 'fissionable':
11✔
107
                cv.check_type('fissionable', value, bool)
11✔
108
                self._constraints['fissionable'] = value
11✔
109
            elif key == 'rejection_strategy':
×
NEW
110
                cv.check_value('rejection strategy',
×
111
                               value, ('resample', 'kill'))
UNCOV
112
                self._constraints['rejection_strategy'] = value
×
113
            else:
NEW
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
            else:
NEW
207
                raise ValueError(
×
208
                    f'Source type {source_type} is not recognized')
209

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

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

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

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

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

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

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

249
        return constraints
11✔
250

251

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

255
    .. versionadded:: 0.14.0
256

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

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

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

306
    .. versionadded:: 0.14.0
307

308
    particle : {'neutron', 'photon'}
309
        Source particle type
310
    constraints : dict
311
        Constraints on sampled source particles. Valid keys include
312
        'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds',
313
        'fissionable', and 'rejection_strategy'.
314

315
    """
316

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

408
    @property
11✔
409
    def particle(self):
11✔
410
        return self._particle
11✔
411

412
    @particle.setter
11✔
413
    def particle(self, particle):
11✔
414
        cv.check_value('source particle', particle, ['neutron', 'photon'])
11✔
415
        self._particle = 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", 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✔
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.num_mesh_cells:
11✔
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.num_mesh_cells}).')
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✔
NEW
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.')
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✔
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
        """
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')
×
790
        if strength is not None:
×
791
            source.strength = float(strength)
×
792

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

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')
×
895
        if strength is not None:
×
896
            kwargs['strength'] = float(strength)
×
897

898
        return cls(**kwargs)
×
899

900

901
class ParticleType(IntEnum):
11✔
902
    """
903
    IntEnum class representing a particle type. Type
904
    values mirror those found in the C++ class.
905
    """
906
    NEUTRON = 0
11✔
907
    PHOTON = 1
11✔
908
    ELECTRON = 2
11✔
909
    POSITRON = 3
11✔
910

911
    @classmethod
11✔
912
    def from_string(cls, value: str):
11✔
913
        """
914
        Constructs a ParticleType instance from a string.
915

916
        Parameters
917
        ----------
918
        value : str
919
            The string representation of the particle type.
920

921
        Returns
922
        -------
923
        The corresponding ParticleType instance.
924
        """
925
        try:
11✔
926
            return cls[value.upper()]
11✔
927
        except KeyError:
11✔
928
            raise ValueError(
11✔
929
                f"Invalid string for creation of {cls.__name__}: {value}")
930

931
    @classmethod
11✔
932
    def from_pdg_number(cls, pdg_number: int) -> ParticleType:
11✔
933
        """Constructs a ParticleType instance from a PDG number.
934

935
        The Particle Data Group at LBNL publishes a Monte Carlo particle
936
        numbering scheme as part of the `Review of Particle Physics
937
        <10.1103/PhysRevD.110.030001>`_. This method maps PDG numbers to the
938
        corresponding :class:`ParticleType`.
939

940
        Parameters
941
        ----------
942
        pdg_number : int
943
            The PDG number of the particle type.
944

945
        Returns
946
        -------
947
        The corresponding ParticleType instance.
948
        """
949
        try:
11✔
950
            return {
11✔
951
                2112: ParticleType.NEUTRON,
952
                22: ParticleType.PHOTON,
953
                11: ParticleType.ELECTRON,
954
                -11: ParticleType.POSITRON,
955
            }[pdg_number]
956
        except KeyError:
×
957
            raise ValueError(f"Unrecognized PDG number: {pdg_number}")
×
958

959
    def __repr__(self) -> str:
11✔
960
        """
961
        Returns a string representation of the ParticleType instance.
962

963
        Returns:
964
            str: The lowercase name of the ParticleType instance.
965
        """
966
        return self.name.lower()
11✔
967

968
    # needed for < Python 3.11
969
    def __str__(self) -> str:
11✔
970
        return self.__repr__()
11✔
971

972

973
class SourceParticle:
11✔
974
    """Source particle
975

976
    This class can be used to create source particles that can be written to a
977
    file and used by OpenMC
978

979
    Parameters
980
    ----------
981
    r : iterable of float
982
        Position of particle in Cartesian coordinates
983
    u : iterable of float
984
        Directional cosines
985
    E : float
986
        Energy of particle in [eV]
987
    time : float
988
        Time of particle in [s]
989
    wgt : float
990
        Weight of the particle
991
    delayed_group : int
992
        Delayed group particle was created in (neutrons only)
993
    surf_id : int
994
        Surface ID where particle is at, if any.
995
    particle : ParticleType
996
        Type of the particle
997

998
    """
999

1000
    def __init__(
11✔
1001
        self,
1002
        r: Iterable[float] = (0., 0., 0.),
1003
        u: Iterable[float] = (0., 0., 1.),
1004
        E: float = 1.0e6,
1005
        time: float = 0.0,
1006
        wgt: float = 1.0,
1007
        delayed_group: int = 0,
1008
        surf_id: int = 0,
1009
        particle: ParticleType = ParticleType.NEUTRON
1010
    ):
1011

1012
        self.r = tuple(r)
11✔
1013
        self.u = tuple(u)
11✔
1014
        self.E = float(E)
11✔
1015
        self.time = float(time)
11✔
1016
        self.wgt = float(wgt)
11✔
1017
        self.delayed_group = delayed_group
11✔
1018
        self.surf_id = surf_id
11✔
1019
        self.particle = particle
11✔
1020

1021
    def __repr__(self):
11✔
1022
        name = self.particle.name.lower()
×
1023
        return f'<SourceParticle: {name} at E={self.E:.6e} eV>'
×
1024

1025
    def to_tuple(self) -> tuple:
11✔
1026
        """Return source particle attributes as a tuple
1027

1028
        Returns
1029
        -------
1030
        tuple
1031
            Source particle attributes
1032

1033
        """
1034
        return (self.r, self.u, self.E, self.time, self.wgt,
11✔
1035
                self.delayed_group, self.surf_id, self.particle.value)
1036

1037

1038
def write_source_file(
11✔
1039
    source_particles: Iterable[SourceParticle],
1040
    filename: PathLike, **kwargs
1041
):
1042
    """Write a source file using a collection of source particles
1043

1044
    Parameters
1045
    ----------
1046
    source_particles : iterable of SourceParticle
1047
        Source particles to write to file
1048
    filename : str or path-like
1049
        Path to source file to write
1050
    **kwargs
1051
        Keyword arguments to pass to :class:`h5py.File`
1052

1053
    See Also
1054
    --------
1055
    openmc.SourceParticle
1056

1057
    """
1058
    cv.check_iterable_type(
11✔
1059
        "source particles", source_particles, SourceParticle)
1060
    pl = ParticleList(source_particles)
11✔
1061
    pl.export_to_hdf5(filename, **kwargs)
11✔
1062

1063

1064
class ParticleList(list):
11✔
1065
    """A collection of SourceParticle objects.
1066

1067
    Parameters
1068
    ----------
1069
    particles : list of SourceParticle
1070
        Particles to collect into the list
1071

1072
    """
1073
    @classmethod
11✔
1074
    def from_hdf5(cls, filename: PathLike) -> ParticleList:
11✔
1075
        """Create particle list from an HDF5 file.
1076

1077
        Parameters
1078
        ----------
1079
        filename : path-like
1080
            Path to source file to read.
1081

1082
        Returns
1083
        -------
1084
        ParticleList instance
1085

1086
        """
1087
        with h5py.File(filename, 'r') as fh:
11✔
1088
            filetype = fh.attrs['filetype']
11✔
1089
            arr = fh['source_bank'][...]
11✔
1090

1091
        if filetype != b'source':
11✔
1092
            raise ValueError(f'File {filename} is not a source file')
×
1093

1094
        source_particles = [
11✔
1095
            SourceParticle(*params, ParticleType(particle))
1096
            for *params, particle in arr
1097
        ]
1098
        return cls(source_particles)
11✔
1099

1100
    @classmethod
11✔
1101
    def from_mcpl(cls, filename: PathLike) -> ParticleList:
11✔
1102
        """Create particle list from an MCPL file.
1103

1104
        Parameters
1105
        ----------
1106
        filename : path-like
1107
            Path to MCPL file to read.
1108

1109
        Returns
1110
        -------
1111
        ParticleList instance
1112

1113
        """
1114
        import mcpl
×
1115
        # Process .mcpl file
1116
        particles = []
×
1117
        with mcpl.MCPLFile(filename) as f:
×
1118
            for particle in f.particles:
×
1119
                # Determine particle type based on the PDG number
1120
                try:
×
NEW
1121
                    particle_type = ParticleType.from_pdg_number(
×
1122
                        particle.pdgcode)
1123
                except ValueError:
×
1124
                    particle_type = "UNKNOWN"
×
1125

1126
                # Create a source particle instance. Note that MCPL stores
1127
                # energy in MeV and time in ms.
1128
                source_particle = SourceParticle(
×
1129
                    r=tuple(particle.position),
1130
                    u=tuple(particle.direction),
1131
                    E=1.0e6*particle.ekin,
1132
                    time=1.0e-3*particle.time,
1133
                    wgt=particle.weight,
1134
                    particle=particle_type
1135
                )
1136
                particles.append(source_particle)
×
1137

1138
        return cls(particles)
×
1139

1140
    def __getitem__(self, index):
11✔
1141
        """
1142
        Return a new ParticleList object containing the particle(s)
1143
        at the specified index or slice.
1144

1145
        Parameters
1146
        ----------
1147
        index : int, slice or list
1148
            The index, slice or list to select from the list of particles
1149

1150
        Returns
1151
        -------
1152
        openmc.ParticleList or openmc.SourceParticle
1153
            A new object with the selected particle(s)
1154
        """
1155
        if isinstance(index, int):
11✔
1156
            # If it's a single integer, return the corresponding particle
1157
            return super().__getitem__(index)
11✔
1158
        elif isinstance(index, slice):
11✔
1159
            # If it's a slice, return a new ParticleList object with the
1160
            # sliced particles
1161
            return ParticleList(super().__getitem__(index))
11✔
1162
        elif isinstance(index, list):
11✔
1163
            # If it's a list of integers, return a new ParticleList object with
1164
            # the selected particles. Note that Python 3.10 gets confused if you
1165
            # use super() here, so we call list.__getitem__ directly.
1166
            return ParticleList([list.__getitem__(self, i) for i in index])
11✔
1167
        else:
1168
            raise TypeError(f"Invalid index type: {type(index)}. Must be int, "
×
1169
                            "slice, or list of int.")
1170

1171
    def to_dataframe(self) -> pd.DataFrame:
11✔
1172
        """A dataframe representing the source particles
1173

1174
        Returns
1175
        -------
1176
        pandas.DataFrame
1177
            DataFrame containing the source particles attributes.
1178
        """
1179
        # Extract the attributes of the source particles into a list of tuples
1180
        data = [(sp.r[0], sp.r[1], sp.r[2], sp.u[0], sp.u[1], sp.u[2],
11✔
1181
                 sp.E, sp.time, sp.wgt, sp.delayed_group, sp.surf_id,
1182
                 sp.particle.name.lower()) for sp in self]
1183

1184
        # Define the column names for the DataFrame
1185
        columns = ['x', 'y', 'z', 'u_x', 'u_y', 'u_z', 'E', 'time', 'wgt',
11✔
1186
                   'delayed_group', 'surf_id', 'particle']
1187

1188
        # Create the pandas DataFrame from the data
1189
        return pd.DataFrame(data, columns=columns)
11✔
1190

1191
    def export_to_hdf5(self, filename: PathLike, **kwargs):
11✔
1192
        """Export particle list to an HDF5 file.
1193

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

1197
        Parameters
1198
        ----------
1199
        filename : path-like
1200
            Path to source file to write
1201
        **kwargs
1202
            Keyword arguments to pass to :class:`h5py.File`
1203

1204
        See Also
1205
        --------
1206
        openmc.FileSource
1207

1208
        """
1209
        # Create compound datatype for source particles
1210
        pos_dtype = np.dtype([('x', '<f8'), ('y', '<f8'), ('z', '<f8')])
11✔
1211
        source_dtype = np.dtype([
11✔
1212
            ('r', pos_dtype),
1213
            ('u', pos_dtype),
1214
            ('E', '<f8'),
1215
            ('time', '<f8'),
1216
            ('wgt', '<f8'),
1217
            ('delayed_group', '<i4'),
1218
            ('surf_id', '<i4'),
1219
            ('particle', '<i4'),
1220
        ])
1221

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

1225
        # Write array to file
1226
        kwargs.setdefault('mode', 'w')
11✔
1227
        with h5py.File(filename, **kwargs) as fh:
11✔
1228
            fh.attrs['filetype'] = np.bytes_("source")
11✔
1229
            fh.create_dataset('source_bank', data=arr, dtype=source_dtype)
11✔
1230

1231

1232
def read_source_file(filename: PathLike) -> ParticleList:
11✔
1233
    """Read a source file and return a list of source particles.
1234

1235
    .. versionadded:: 0.15.0
1236

1237
    Parameters
1238
    ----------
1239
    filename : str or path-like
1240
        Path to source file to read
1241

1242
    Returns
1243
    -------
1244
    openmc.ParticleList
1245

1246
    See Also
1247
    --------
1248
    openmc.SourceParticle
1249

1250
    """
1251
    filename = Path(filename)
11✔
1252
    if filename.suffix not in ('.h5', '.mcpl'):
11✔
1253
        raise ValueError('Source file must have a .h5 or .mcpl extension.')
×
1254

1255
    if filename.suffix == '.h5':
11✔
1256
        return ParticleList.from_hdf5(filename)
11✔
1257
    else:
1258
        return ParticleList.from_mcpl(filename)
×
1259

1260

1261
def read_collision_track_hdf5(filename):
11✔
1262
    """Read a collision track file in HDF5 format.
1263

1264
    Parameters
1265
    ----------
1266
    filename : str or path-like
1267
        Path to the HDF5 collision track file.
1268

1269
    Returns
1270
    -------
1271
    numpy.ndarray
1272
        Structured array containing collision track data.
1273

1274
    See Also
1275
    --------
1276
    read_collision_track_mcpl
1277
    read_collision_track_file
1278
    """
1279

1280
    with h5py.File(filename, 'r') as file:
11✔
1281
        data = file['collision_track_bank'][:]
11✔
1282

1283
    return data
11✔
1284

1285

1286
def read_collision_track_mcpl(file_path):
11✔
1287
    """Read a collision track file in MCPL format.
1288

1289
    Parameters
1290
    ----------
1291
    file_path : str or path-like
1292
        Path to the MCPL collision track file.
1293

1294
    Returns
1295
    -------
1296
    numpy.ndarray
1297
        Structured array of particle collision track information, including
1298
        position, direction, energy, weight, reaction data, and identifiers.
1299

1300
    See Also
1301
    --------
1302
    read_collision_track_hdf5
1303
    read_collision_track_file
1304
    """
1305
    import mcpl
11✔
1306
    myfile = mcpl.MCPLFile(file_path)
11✔
1307
    data = {
11✔
1308
        'r': [],  # for position (x, y, z)
1309
        'u': [],  # for direction (ux, uy, uz)
1310
        'E': [], 'dE': [], 'time': [],
1311
        'wgt': [], 'event_mt': [], 'delayed_group': [],
1312
        'cell_id': [], 'nuclide_id': [], 'material_id': [],
1313
        'universe_id': [], 'n_collision': [], 'particle': [],
1314
        'parent_id': [], 'progeny_id': []
1315
    }
1316

1317
    # Read and collect data from the MCPL file
1318
    for i, p in enumerate(myfile.particles):
11✔
1319
        if f'blob_{i}' in myfile.blobs:
11✔
1320
            blob_data = myfile.blobs[f'blob_{i}']
11✔
1321
            decoded_str = blob_data.decode('utf-8')
11✔
1322
            pairs = decoded_str.split(';')
11✔
1323
            values_dict = {k.strip(): v.strip()
11✔
1324
                           for k, v in (pair.split(':') for pair in pairs if pair.strip())}
1325

1326
            data['r'].append((p.x, p.y, p.z))  # Append as tuple
11✔
1327
            data['u'].append((p.ux, p.uy, p.uz))  # Append as tuple
11✔
1328
            data['E'].append(p.ekin * 1e6)
11✔
1329
            data['dE'].append(float(values_dict.get('dE', 0)))
11✔
1330
            data['time'].append(p.time * 1e-3)
11✔
1331
            data['wgt'].append(p.weight)
11✔
1332
            data['event_mt'].append(int(values_dict.get('event_mt', 0)))
11✔
1333
            data['delayed_group'].append(
11✔
1334
                int(values_dict.get('delayed_group', 0)))
1335
            data['cell_id'].append(int(values_dict.get('cell_id', 0)))
11✔
1336
            data['nuclide_id'].append(int(values_dict.get('nuclide_id', 0)))
11✔
1337
            data['material_id'].append(int(values_dict.get('material_id', 0)))
11✔
1338
            data['universe_id'].append(int(values_dict.get('universe_id', 0)))
11✔
1339
            data['n_collision'].append(int(values_dict.get('n_collision', 0)))
11✔
1340
            data['particle'].append(ParticleType.from_pdg_number(p.pdgcode))
11✔
1341
            data['parent_id'].append(int(values_dict.get('parent_id', 0)))
11✔
1342
            data['progeny_id'].append(int(values_dict.get('progeny_id', 0)))
11✔
1343

1344
    dtypes = [
11✔
1345
        ('r', [('x', 'f8'), ('y', 'f8'), ('z', 'f8')]),
1346
        ('u', [('x', 'f8'), ('y', 'f8'), ('z', 'f8')]),
1347
        ('E', 'f8'), ('dE', 'f8'), ('time', 'f8'), ('wgt', 'f8'),
1348
        ('event_mt', 'f8'), ('delayed_group', 'i4'), ('cell_id', 'i4'),
1349
        ('nuclide_id', 'i4'), ('material_id', 'i4'), ('universe_id', 'i4'),
1350
        ('n_collision', 'i4'), ('particle', 'i4'),
1351
        ('parent_id', 'i8'), ('progeny_id', 'i8')
1352
    ]
1353

1354
    structured_array = np.zeros(len(data['r']), dtype=dtypes)
11✔
1355
    for key in data:
11✔
1356
        structured_array[key] = data[key]  # Assign data
11✔
1357

1358
    return structured_array
11✔
1359

1360

1361
def read_collision_track_file(filename):
11✔
1362
    """Read a collision track file (HDF5 or MCPL) and return its data.
1363

1364
    Parameters
1365
    ----------
1366
    filename : str or path-like
1367
        Path to the collision track file to read. Must end with
1368
        ``.h5`` or ``.mcpl``.
1369

1370
    Returns
1371
    -------
1372
    numpy.ndarray
1373
        Structured array containing collision track data.
1374

1375
    See Also
1376
    --------
1377
    read_collision_track_hdf5
1378
    read_collision_track_mcpl
1379
    """
1380

1381
    filename = Path(filename)
11✔
1382
    if filename.suffix not in ('.h5', '.mcpl'):
11✔
NEW
1383
        raise ValueError('Retina file must have a .h5 or .mcpl extension.')
×
1384

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

© 2026 Coveralls, Inc