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

openmc-dev / openmc / 18908039831

29 Oct 2025 12:34PM UTC coverage: 81.875% (+0.06%) from 81.812%
18908039831

Pull #3417

github

web-flow
Merge 0db1f0918 into 4c4176661
Pull Request #3417: Addition of a collision tracking feature

16866 of 23521 branches covered (71.71%)

Branch coverage included in aggregate %.

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

1 existing line in 1 file now uncovered.

54305 of 63405 relevant lines covered (85.65%)

42524539.39 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', 'electron', 'positron'}
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', 'electron', 'positron'}
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,
11✔
415
                       ['neutron', 'photon', 'electron', 'positron'])
416
        self._particle = particle
11✔
417

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

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

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

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

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

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

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

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

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

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

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

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

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

482
        return source
11✔
483

484

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

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

495
    .. versionadded:: 0.15.0
496

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

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

536
    """
537

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

663

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

673

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

677
    .. versionadded:: 0.14.0
678

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

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

716
    """
717

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

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

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

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

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

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

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

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

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

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

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

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

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

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

788
        source = cls(**kwargs)
×
789

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

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

798
        return source
×
799

800

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

804
    .. versionadded:: 0.14.0
805

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

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

839
    """
840

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

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

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

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

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

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

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

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

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

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

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

899
        return cls(**kwargs)
×
900

901

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

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

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

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

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

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

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

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

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

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

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

973

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

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

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

999
    """
1000

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

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

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

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

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

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

1038

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

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

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

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

1064

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

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

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

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

1083
        Returns
1084
        -------
1085
        ParticleList instance
1086

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

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

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

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

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

1110
        Returns
1111
        -------
1112
        ParticleList instance
1113

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

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

1139
        return cls(particles)
×
1140

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1232

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

1236
    .. versionadded:: 0.15.0
1237

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

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

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

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

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

1261

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

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

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

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

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

1284
    return data
11✔
1285

1286

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

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

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

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

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

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

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

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

1359
    return structured_array
11✔
1360

1361

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

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

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

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

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

1386
    if filename.suffix == '.h5':
11✔
1387
        return read_collision_track_hdf5(filename)
11✔
1388
    else:
1389
        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