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

openmc-dev / openmc / 20163164157

12 Dec 2025 10:01AM UTC coverage: 81.832% (-0.04%) from 81.872%
20163164157

Pull #3279

github

web-flow
Merge c32841e75 into bbfa18d72
Pull Request #3279: Hexagonal mesh model

17271 of 24053 branches covered (71.8%)

Branch coverage included in aggregate %.

602 of 876 new or added lines in 9 files covered. (68.72%)

1836 existing lines in 44 files now uncovered.

55648 of 65055 relevant lines covered (85.54%)

42918761.78 hits per line

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

86.2
/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

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

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

24

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

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

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

57
    """
58

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

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

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

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

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

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

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

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

125
        """
126

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

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

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

165
        return element
11✔
166

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

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

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

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

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

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

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

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

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

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

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

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

248
        return constraints
11✔
249

250

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

254
    .. versionadded:: 0.14.0
255

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

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

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

305
    .. versionadded:: 0.14.0
306

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

314
    """
315

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

411
    @particle.setter
11✔
412
    def particle(self, particle):
11✔
413
        cv.check_value('source particle', particle,
11✔
414
                       ['neutron', 'photon', 'electron', 'positron'])
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✔
UNCOV
479
            source.time = Univariate.from_xml_element(time)
×
480

481
        return source
11✔
482

483

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

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

494
    .. versionadded:: 0.15.0
495

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

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

535
    """
536

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

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

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

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

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

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

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

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

574
        if isinstance(self.mesh, StructuredMesh):
11✔
575
            if s.size != self.mesh.num_mesh_cells:
11✔
UNCOV
576
                raise ValueError(
×
577
                    f'The length of the source array ({s.size}) does not match '
578
                    f'the number of mesh elements ({self.mesh.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✔
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✔
UNCOV
593
                warnings.warn('Some sources on the mesh have spatial '
×
594
                              'distributions that will be ignored at runtime.')
UNCOV
595
                break
×
596

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

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

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

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

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

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

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

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

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

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

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

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

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

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

662

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

672

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

676
    .. versionadded:: 0.14.0
677

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

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

715
    """
716

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
787
        source = cls(**kwargs)
×
788

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

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

UNCOV
797
        return source
×
798

799

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

803
    .. versionadded:: 0.14.0
804

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

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

838
    """
839

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

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

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

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

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

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

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

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

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

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

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

UNCOV
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]
UNCOV
956
        except KeyError:
×
UNCOV
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✔
UNCOV
1022
        name = self.particle.name.lower()
×
UNCOV
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✔
UNCOV
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
        """
UNCOV
1114
        import mcpl
×
1115
        # Process .mcpl file
UNCOV
1116
        particles = []
×
UNCOV
1117
        with mcpl.MCPLFile(filename) as f:
×
UNCOV
1118
            for particle in f.particles:
×
1119
                # Determine particle type based on the PDG number
UNCOV
1120
                try:
×
1121
                    particle_type = ParticleType.from_pdg_number(
×
1122
                        particle.pdgcode)
1123
                except ValueError:
×
UNCOV
1124
                    particle_type = "UNKNOWN"
×
1125

1126
                # Create a source particle instance. Note that MCPL stores
1127
                # energy in MeV and time in ms.
UNCOV
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
                )
UNCOV
1136
                particles.append(source_particle)
×
1137

UNCOV
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:
UNCOV
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✔
UNCOV
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:
UNCOV
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✔
UNCOV
1383
        raise ValueError('Collision track 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