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

openmc-dev / openmc / 19058781736

04 Nov 2025 05:26AM UTC coverage: 82.008% (-3.1%) from 85.155%
19058781736

Pull #3252

github

web-flow
Merge b8a72730f into bd76fc056
Pull Request #3252: Adding vtkhdf option to write vtk data

16714 of 23236 branches covered (71.93%)

Branch coverage included in aggregate %.

61 of 66 new or added lines in 1 file covered. (92.42%)

3175 existing lines in 103 files now uncovered.

54243 of 63288 relevant lines covered (85.71%)

43393337.77 hits per line

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

93.36
/openmc/material.py
1
from __future__ import annotations
11✔
2
from collections import defaultdict, namedtuple, Counter
11✔
3
from collections.abc import Iterable
11✔
4
from copy import deepcopy
11✔
5
from numbers import Real
11✔
6
from pathlib import Path
11✔
7
import re
11✔
8
import sys
11✔
9
import tempfile
11✔
10
from typing import Sequence, Dict
11✔
11
import warnings
11✔
12

13
import lxml.etree as ET
11✔
14
import numpy as np
11✔
15
import h5py
11✔
16

17
import openmc
11✔
18
import openmc.data
11✔
19
import openmc.checkvalue as cv
11✔
20
from ._xml import clean_indentation, get_elem_list, get_text
11✔
21
from .mixin import IDManagerMixin
11✔
22
from .utility_funcs import input_path
11✔
23
from . import waste
11✔
24
from openmc.checkvalue import PathLike
11✔
25
from openmc.stats import Univariate, Discrete, Mixture
11✔
26
from openmc.data.data import _get_element_symbol
11✔
27

28

29
# Units for density supported by OpenMC
30
DENSITY_UNITS = ('g/cm3', 'g/cc', 'kg/m3', 'atom/b-cm', 'atom/cm3', 'sum',
11✔
31
                 'macro')
32

33
# Smallest normalized floating point number
34
_SMALLEST_NORMAL = sys.float_info.min
11✔
35

36
_BECQUEREL_PER_CURIE = 3.7e10
11✔
37

38
NuclideTuple = namedtuple('NuclideTuple', ['name', 'percent', 'percent_type'])
11✔
39

40

41
class Material(IDManagerMixin):
11✔
42
    """A material composed of a collection of nuclides/elements.
43

44
    To create a material, one should create an instance of this class, add
45
    nuclides or elements with :meth:`Material.add_nuclide` or
46
    :meth:`Material.add_element`, respectively, and set the total material
47
    density with :meth:`Material.set_density()`. Alternatively, you can use
48
    :meth:`Material.add_components()` to pass a dictionary containing all the
49
    component information. The material can then be assigned to a cell using the
50
    :attr:`Cell.fill` attribute.
51

52
    Parameters
53
    ----------
54
    material_id : int, optional
55
        Unique identifier for the material. If not specified, an identifier will
56
        automatically be assigned.
57
    name : str, optional
58
        Name of the material. If not specified, the name will be the empty
59
        string.
60
    temperature : float, optional
61
        Temperature of the material in Kelvin. If not specified, the material
62
        inherits the default temperature applied to the model.
63

64
    Attributes
65
    ----------
66
    id : int
67
        Unique identifier for the material
68
    temperature : float
69
        Temperature of the material in Kelvin.
70
    density : float
71
        Density of the material (units defined separately)
72
    density_units : str
73
        Units used for `density`. Can be one of 'g/cm3', 'g/cc', 'kg/m3',
74
        'atom/b-cm', 'atom/cm3', 'sum', or 'macro'.  The 'macro' unit only
75
        applies in the case of a multi-group calculation.
76
    depletable : bool
77
        Indicate whether the material is depletable.
78
    nuclides : list of namedtuple
79
        List in which each item is a namedtuple consisting of a nuclide string,
80
        the percent density, and the percent type ('ao' or 'wo'). The namedtuple
81
        has field names ``name``, ``percent``, and ``percent_type``.
82
    isotropic : list of str
83
        Nuclides for which elastic scattering should be treated as though it
84
        were isotropic in the laboratory system.
85
    average_molar_mass : float
86
        The average molar mass of nuclides in the material in units of grams per
87
        mol.  For example, UO2 with 3 nuclides will have an average molar mass
88
        of 270 / 3 = 90 g / mol.
89
    volume : float
90
        Volume of the material in cm^3. This can either be set manually or
91
        calculated in a stochastic volume calculation and added via the
92
        :meth:`Material.add_volume_information` method.
93
    paths : list of str
94
        The paths traversed through the CSG tree to reach each material
95
        instance. This property is initialized by calling the
96
        :meth:`Geometry.determine_paths` method.
97
    num_instances : int
98
        The number of instances of this material throughout the geometry. This
99
        property is initialized by calling the :meth:`Geometry.determine_paths`
100
        method.
101
    fissionable_mass : float
102
        Mass of fissionable nuclides in the material in [g]. Requires that the
103
        :attr:`volume` attribute is set.
104
    ncrystal_cfg : str
105
        NCrystal configuration string
106

107
        .. versionadded:: 0.13.3
108

109
    """
110

111
    next_id = 1
11✔
112
    used_ids = set()
11✔
113

114
    def __init__(self, material_id=None, name='', temperature=None):
11✔
115
        # Initialize class attributes
116
        self.id = material_id
11✔
117
        self.name = name
11✔
118
        self.temperature = temperature
11✔
119
        self._density = None
11✔
120
        self._density_units = 'sum'
11✔
121
        self._depletable = False
11✔
122
        self._paths = None
11✔
123
        self._num_instances = None
11✔
124
        self._volume = None
11✔
125
        self._atoms = {}
11✔
126
        self._isotropic = []
11✔
127
        self._ncrystal_cfg = None
11✔
128

129
        # A list of tuples (nuclide, percent, percent type)
130
        self._nuclides = []
11✔
131

132
        # The single instance of Macroscopic data present in this material
133
        # (only one is allowed, hence this is different than _nuclides, etc)
134
        self._macroscopic = None
11✔
135

136
        # If specified, a list of table names
137
        self._sab = []
11✔
138

139
    def __repr__(self) -> str:
11✔
140
        string = 'Material\n'
11✔
141
        string += '{: <16}=\t{}\n'.format('\tID', self._id)
11✔
142
        string += '{: <16}=\t{}\n'.format('\tName', self._name)
11✔
143
        string += '{: <16}=\t{}\n'.format('\tTemperature', self._temperature)
11✔
144

145
        string += '{: <16}=\t{}'.format('\tDensity', self._density)
11✔
146
        string += f' [{self._density_units}]\n'
11✔
147

148
        string += '{: <16}=\t{} [cm^3]\n'.format('\tVolume', self._volume)
11✔
149
        string += '{: <16}=\t{}\n'.format('\tDepletable', self._depletable)
11✔
150

151
        string += '{: <16}\n'.format('\tS(a,b) Tables')
11✔
152

153
        if self._ncrystal_cfg:
11✔
154
            string += '{: <16}=\t{}\n'.format('\tNCrystal conf', self._ncrystal_cfg)
×
155

156
        for sab in self._sab:
11✔
157
            string += '{: <16}=\t{}\n'.format('\tS(a,b)', sab)
11✔
158

159
        string += '{: <16}\n'.format('\tNuclides')
11✔
160

161
        for nuclide, percent, percent_type in self._nuclides:
11✔
162
            string += '{: <16}'.format('\t{}'.format(nuclide))
11✔
163
            string += f'=\t{percent: <12} [{percent_type}]\n'
11✔
164

165
        if self._macroscopic is not None:
11✔
166
            string += '{: <16}\n'.format('\tMacroscopic Data')
11✔
167
            string += '{: <16}'.format('\t{}'.format(self._macroscopic))
11✔
168

169
        return string
11✔
170

171
    @property
11✔
172
    def name(self) -> str | None:
11✔
173
        return self._name
11✔
174

175
    @name.setter
11✔
176
    def name(self, name: str | None):
11✔
177
        if name is not None:
11✔
178
            cv.check_type(f'name for Material ID="{self._id}"',
11✔
179
                          name, str)
180
            self._name = name
11✔
181
        else:
182
            self._name = ''
11✔
183

184
    @property
11✔
185
    def temperature(self) -> float | None:
11✔
186
        return self._temperature
11✔
187

188
    @temperature.setter
11✔
189
    def temperature(self, temperature: Real | None):
11✔
190
        cv.check_type(f'Temperature for Material ID="{self._id}"',
11✔
191
                      temperature, (Real, type(None)))
192
        self._temperature = temperature
11✔
193

194
    @property
11✔
195
    def density(self) -> float | None:
11✔
196
        return self._density
11✔
197

198
    @property
11✔
199
    def density_units(self) -> str:
11✔
200
        return self._density_units
11✔
201

202
    @property
11✔
203
    def depletable(self) -> bool:
11✔
204
        return self._depletable
11✔
205

206
    @depletable.setter
11✔
207
    def depletable(self, depletable: bool):
11✔
208
        cv.check_type(f'Depletable flag for Material ID="{self._id}"',
11✔
209
                      depletable, bool)
210
        self._depletable = depletable
11✔
211

212
    @property
11✔
213
    def paths(self) -> list[str]:
11✔
214
        if self._paths is None:
11✔
215
            raise ValueError('Material instance paths have not been determined. '
×
216
                             'Call the Geometry.determine_paths() method.')
217
        return self._paths
11✔
218

219
    @property
11✔
220
    def num_instances(self) -> int:
11✔
221
        if self._num_instances is None:
11✔
222
            raise ValueError(
×
223
                'Number of material instances have not been determined. Call '
224
                'the Geometry.determine_paths() method.')
225
        return self._num_instances
11✔
226

227
    @property
11✔
228
    def nuclides(self) -> list[namedtuple]:
11✔
229
        return self._nuclides
11✔
230

231
    @property
11✔
232
    def isotropic(self) -> list[str]:
11✔
233
        return self._isotropic
11✔
234

235
    @isotropic.setter
11✔
236
    def isotropic(self, isotropic: Iterable[str]):
11✔
237
        cv.check_iterable_type('Isotropic scattering nuclides', isotropic,
11✔
238
                               str)
239
        self._isotropic = list(isotropic)
11✔
240

241
    @property
11✔
242
    def average_molar_mass(self) -> float:
11✔
243
        # Using the sum of specified atomic or weight amounts as a basis, sum
244
        # the mass and moles of the material
245
        mass = 0.
11✔
246
        moles = 0.
11✔
247
        for nuc in self.nuclides:
11✔
248
            if nuc.percent_type == 'ao':
11✔
249
                mass += nuc.percent * openmc.data.atomic_mass(nuc.name)
11✔
250
                moles += nuc.percent
11✔
251
            else:
252
                moles += nuc.percent / openmc.data.atomic_mass(nuc.name)
11✔
253
                mass += nuc.percent
11✔
254

255
        # Compute and return the molar mass
256
        return mass / moles
11✔
257

258
    @property
11✔
259
    def volume(self) -> float | None:
11✔
260
        return self._volume
11✔
261

262
    @volume.setter
11✔
263
    def volume(self, volume: Real):
11✔
264
        if volume is not None:
11✔
265
            cv.check_type('material volume', volume, Real)
11✔
266
        self._volume = volume
11✔
267

268
    @property
11✔
269
    def ncrystal_cfg(self) -> str | None:
11✔
270
        return self._ncrystal_cfg
11✔
271

272
    @property
11✔
273
    def fissionable_mass(self) -> float:
11✔
274
        if self.volume is None:
11✔
275
            raise ValueError("Volume must be set in order to determine mass.")
×
276
        density = 0.0
11✔
277
        for nuc, atoms_per_bcm in self.get_nuclide_atom_densities().items():
11✔
278
            Z = openmc.data.zam(nuc)[0]
11✔
279
            if Z >= 90:
11✔
280
                density += 1e24 * atoms_per_bcm * openmc.data.atomic_mass(nuc) \
11✔
281
                           / openmc.data.AVOGADRO
282
        return density*self.volume
11✔
283

284
    @property
11✔
285
    def decay_photon_energy(self) -> Univariate | None:
11✔
286
        warnings.warn(
×
287
            "The 'decay_photon_energy' property has been replaced by the "
288
            "get_decay_photon_energy() method and will be removed in a future "
289
            "version.", FutureWarning)
290
        return self.get_decay_photon_energy(0.0)
×
291

292
    def get_decay_photon_energy(
11✔
293
        self,
294
        clip_tolerance: float = 1e-6,
295
        units: str = 'Bq',
296
        volume: float | None = None,
297
        exclude_nuclides: list[str] | None = None,
298
        include_nuclides: list[str] | None = None
299
    ) -> Univariate | None:
300
        r"""Return energy distribution of decay photons from unstable nuclides.
301

302
        .. versionadded:: 0.14.0
303

304
        Parameters
305
        ----------
306
        clip_tolerance : float
307
            Maximum fraction of :math:`\sum_i x_i p_i` for discrete distributions
308
            that will be discarded.
309
        units : {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3'}
310
            Specifies the units on the integral of the distribution.
311
        volume : float, optional
312
            Volume of the material. If not passed, defaults to using the
313
            :attr:`Material.volume` attribute.
314
        exclude_nuclides : list of str, optional
315
            Nuclides to exclude from the photon source calculation.
316
        include_nuclides : list of str, optional
317
            Nuclides to include in the photon source calculation. If specified,
318
            only these nuclides are used.
319

320
        Returns
321
        -------
322
        Univariate or None
323
            Decay photon energy distribution. The integral of this distribution is
324
            the total intensity of the photon source in the requested units.
325

326
        """
327
        cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3'})
11✔
328

329
        if exclude_nuclides is not None and include_nuclides is not None:
11✔
UNCOV
330
            raise ValueError("Cannot specify both exclude_nuclides and include_nuclides")
×
331

332
        if units == 'Bq':
11✔
333
            multiplier = volume if volume is not None else self.volume
11✔
334
            if multiplier is None:
11✔
UNCOV
335
                raise ValueError("volume must be specified if units='Bq'")
×
336
        elif units == 'Bq/cm3':
11✔
337
            multiplier = 1
11✔
338
        elif units == 'Bq/g':
11✔
339
            multiplier = 1.0 / self.get_mass_density()
11✔
340
        elif units == 'Bq/kg':
11✔
341
            multiplier = 1000.0 / self.get_mass_density()
11✔
342

343
        dists = []
11✔
344
        probs = []
11✔
345
        for nuc, atoms_per_bcm in self.get_nuclide_atom_densities().items():
11✔
346
            if exclude_nuclides is not None and nuc in exclude_nuclides:
11✔
UNCOV
347
                continue
×
348
            if include_nuclides is not None and nuc not in include_nuclides:
11✔
UNCOV
349
                continue
×
350

351
            source_per_atom = openmc.data.decay_photon_energy(nuc)
11✔
352
            if source_per_atom is not None and atoms_per_bcm > 0.0:
11✔
353
                dists.append(source_per_atom)
11✔
354
                probs.append(1e24 * atoms_per_bcm * multiplier)
11✔
355

356
        # If no photon sources, exit early
357
        if not dists:
11✔
358
            return None
11✔
359

360
        # Get combined distribution, clip low-intensity values in discrete spectra
361
        combined = openmc.data.combine_distributions(dists, probs)
11✔
362
        if isinstance(combined, (Discrete, Mixture)):
11✔
363
            combined.clip(clip_tolerance, inplace=True)
11✔
364

365
        # If clipping resulted in a single distribution within a mixture, pick
366
        # out that single distribution
367
        if isinstance(combined, Mixture) and len(combined.distribution) == 1:
11✔
UNCOV
368
            combined = combined.distribution[0]
×
369

370
        return combined
11✔
371

372
    @classmethod
11✔
373
    def from_hdf5(cls, group: h5py.Group) -> Material:
11✔
374
        """Create material from HDF5 group
375

376
        Parameters
377
        ----------
378
        group : h5py.Group
379
            Group in HDF5 file
380

381
        Returns
382
        -------
383
        openmc.Material
384
            Material instance
385

386
        """
387
        mat_id = int(group.name.split('/')[-1].lstrip('material '))
11✔
388

389
        name = group['name'][()].decode() if 'name' in group else ''
11✔
390
        density = group['atom_density'][()]
11✔
391
        if 'nuclide_densities' in group:
11✔
392
            nuc_densities = group['nuclide_densities'][()]
11✔
393

394
        # Create the Material
395
        material = cls(mat_id, name)
11✔
396
        material.depletable = bool(group.attrs['depletable'])
11✔
397
        if 'volume' in group.attrs:
11✔
398
            material.volume = group.attrs['volume']
11✔
399
        if "temperature" in group.attrs:
11✔
400
            material.temperature = group.attrs["temperature"]
11✔
401

402
        # Read the names of the S(a,b) tables for this Material and add them
403
        if 'sab_names' in group:
11✔
404
            sab_tables = group['sab_names'][()]
11✔
405
            for sab_table in sab_tables:
11✔
406
                name = sab_table.decode()
11✔
407
                material.add_s_alpha_beta(name)
11✔
408

409
        # Set the Material's density to atom/b-cm as used by OpenMC
410
        material.set_density(density=density, units='atom/b-cm')
11✔
411

412
        if 'nuclides' in group:
11✔
413
            nuclides = group['nuclides'][()]
11✔
414
            # Add all nuclides to the Material
415
            for fullname, density in zip(nuclides, nuc_densities):
11✔
416
                name = fullname.decode().strip()
11✔
417
                material.add_nuclide(name, percent=density, percent_type='ao')
11✔
418
        if 'macroscopics' in group:
11✔
419
            macroscopics = group['macroscopics'][()]
11✔
420
            # Add all macroscopics to the Material
421
            for fullname in macroscopics:
11✔
422
                name = fullname.decode().strip()
11✔
423
                material.add_macroscopic(name)
11✔
424

425
        return material
11✔
426

427
    @classmethod
11✔
428
    def from_ncrystal(cls, cfg, **kwargs) -> Material:
11✔
429
        """Create material from NCrystal configuration string.
430

431
        Density, temperature, and material composition, and (ultimately) thermal
432
        neutron scattering will be automatically be provided by NCrystal based
433
        on this string. The name and material_id parameters are simply passed on
434
        to the Material constructor.
435

436
        .. versionadded:: 0.13.3
437

438
        Parameters
439
        ----------
440
        cfg : str
441
            NCrystal configuration string
442
        **kwargs
443
            Keyword arguments passed to :class:`openmc.Material`
444

445
        Returns
446
        -------
447
        openmc.Material
448
            Material instance
449

450
        """
451

452
        try:
11✔
453
            import NCrystal
11✔
UNCOV
454
        except ModuleNotFoundError as e:
×
UNCOV
455
            raise RuntimeError('The .from_ncrystal method requires'
×
456
                               ' NCrystal to be installed.') from e
457
        nc_mat = NCrystal.createInfo(cfg)
11✔
458

459
        def openmc_natabund(Z):
11✔
460
            #nc_mat.getFlattenedComposition might need natural abundancies.
461
            #This call-back function is used so NCrystal can flatten composition
462
            #using OpenMC's natural abundancies. In practice this function will
463
            #only get invoked in the unlikely case where a material is specified
464
            #by referring both to natural elements and specific isotopes of the
465
            #same element.
466
            elem_name = openmc.data.ATOMIC_SYMBOL[Z]
×
UNCOV
467
            return [
×
468
                (int(iso_name[len(elem_name):]), abund)
469
                for iso_name, abund in openmc.data.isotopes(elem_name)
470
            ]
471

472
        flat_compos = nc_mat.getFlattenedComposition(
11✔
473
            preferNaturalElements=True, naturalAbundProvider=openmc_natabund)
474

475
        # Create the Material
476
        material = cls(temperature=nc_mat.getTemperature(), **kwargs)
11✔
477

478
        for Z, A_vals in flat_compos:
11✔
479
            elemname = openmc.data.ATOMIC_SYMBOL[Z]
11✔
480
            for A, frac in A_vals:
11✔
481
                if A:
11✔
UNCOV
482
                    material.add_nuclide(f'{elemname}{A}', frac)
×
483
                else:
484
                    material.add_element(elemname, frac)
11✔
485

486
        material.set_density('g/cm3', nc_mat.getDensity())
11✔
487
        material._ncrystal_cfg = NCrystal.normaliseCfg(cfg)
11✔
488

489
        return material
11✔
490

491
    def add_volume_information(self, volume_calc):
11✔
492
        """Add volume information to a material.
493

494
        Parameters
495
        ----------
496
        volume_calc : openmc.VolumeCalculation
497
            Results from a stochastic volume calculation
498

499
        """
500
        if volume_calc.domain_type == 'material':
11✔
501
            if self.id in volume_calc.volumes:
11✔
502
                self._volume = volume_calc.volumes[self.id].n
11✔
503
                self._atoms = volume_calc.atoms[self.id]
11✔
504
            else:
UNCOV
505
                raise ValueError('No volume information found for material ID={}.'
×
506
                                 .format(self.id))
507
        else:
UNCOV
508
            raise ValueError(f'No volume information found for material ID={self.id}.')
×
509

510
    def set_density(self, units: str, density: float | None = None):
11✔
511
        """Set the density of the material
512

513
        Parameters
514
        ----------
515
        units : {'g/cm3', 'g/cc', 'kg/m3', 'atom/b-cm', 'atom/cm3', 'sum', 'macro'}
516
            Physical units of density.
517
        density : float, optional
518
            Value of the density. Must be specified unless units is given as
519
            'sum'.
520

521
        """
522

523
        cv.check_value('density units', units, DENSITY_UNITS)
11✔
524
        self._density_units = units
11✔
525

526
        if units == 'sum':
11✔
527
            if density is not None:
11✔
UNCOV
528
                msg = 'Density "{}" for Material ID="{}" is ignored ' \
×
529
                      'because the unit is "sum"'.format(density, self.id)
UNCOV
530
                warnings.warn(msg)
×
531
        else:
532
            if density is None:
11✔
UNCOV
533
                msg = 'Unable to set the density for Material ID="{}" ' \
×
534
                      'because a density value must be given when not using ' \
535
                      '"sum" unit'.format(self.id)
UNCOV
536
                raise ValueError(msg)
×
537

538
            cv.check_type(f'the density for Material ID="{self.id}"',
11✔
539
                          density, Real)
540
            self._density = density
11✔
541

542
    def add_nuclide(self, nuclide: str, percent: float, percent_type: str = 'ao'):
11✔
543
        """Add a nuclide to the material
544

545
        Parameters
546
        ----------
547
        nuclide : str
548
            Nuclide to add, e.g., 'Mo95'
549
        percent : float
550
            Atom or weight percent
551
        percent_type : {'ao', 'wo'}
552
            'ao' for atom percent and 'wo' for weight percent
553

554
        """
555
        cv.check_type('nuclide', nuclide, str)
11✔
556
        cv.check_type('percent', percent, Real)
11✔
557
        cv.check_value('percent type', percent_type, {'ao', 'wo'})
11✔
558
        cv.check_greater_than('percent', percent, 0, equality=True)
11✔
559

560
        if self._macroscopic is not None:
11✔
561
            msg = 'Unable to add a Nuclide to Material ID="{}" as a ' \
11✔
562
                  'macroscopic data-set has already been added'.format(self._id)
563
            raise ValueError(msg)
11✔
564

565
        if self._ncrystal_cfg is not None:
11✔
UNCOV
566
            raise ValueError("Cannot add nuclides to NCrystal material")
×
567

568
        # If nuclide name doesn't look valid, give a warning
569
        try:
11✔
570
            Z, _, _ = openmc.data.zam(nuclide)
11✔
571
        except ValueError as e:
11✔
572
            warnings.warn(str(e))
11✔
573
        else:
574
            # For actinides, have the material be depletable by default
575
            if Z >= 89:
11✔
576
                self.depletable = True
11✔
577

578
        self._nuclides.append(NuclideTuple(nuclide, percent, percent_type))
11✔
579

580
    def add_components(self, components: dict, percent_type: str = 'ao'):
11✔
581
        """ Add multiple elements or nuclides to a material
582

583
        .. versionadded:: 0.13.1
584

585
        Parameters
586
        ----------
587
        components : dict of str to float or dict
588
            Dictionary mapping element or nuclide names to their atom or weight
589
            percent. To specify enrichment of an element, the entry of
590
            ``components`` for that element must instead be a dictionary
591
            containing the keyword arguments as well as a value for
592
            ``'percent'``
593
        percent_type : {'ao', 'wo'}
594
            'ao' for atom percent and 'wo' for weight percent
595

596
        Examples
597
        --------
598
        >>> mat = openmc.Material()
599
        >>> components  = {'Li': {'percent': 1.0,
600
        >>>                       'enrichment': 60.0,
601
        >>>                       'enrichment_target': 'Li7'},
602
        >>>                'Fl': 1.0,
603
        >>>                'Be6': 0.5}
604
        >>> mat.add_components(components)
605

606
        """
607

608
        for component, params in components.items():
11✔
609
            cv.check_type('component', component, str)
11✔
610
            if isinstance(params, Real):
11✔
611
                params = {'percent': params}
11✔
612

613
            else:
614
                cv.check_type('params', params, dict)
11✔
615
                if 'percent' not in params:
11✔
UNCOV
616
                    raise ValueError("An entry in the dictionary does not have "
×
617
                                     "a required key: 'percent'")
618

619
            params['percent_type'] = percent_type
11✔
620

621
            # check if nuclide
622
            if not component.isalpha():
11✔
623
                self.add_nuclide(component, **params)
11✔
624
            else:
625
                self.add_element(component, **params)
11✔
626

627
    def remove_nuclide(self, nuclide: str):
11✔
628
        """Remove a nuclide from the material
629

630
        Parameters
631
        ----------
632
        nuclide : str
633
            Nuclide to remove
634

635
        """
636
        cv.check_type('nuclide', nuclide, str)
11✔
637

638
        # If the Material contains the Nuclide, delete it
639
        for nuc in reversed(self.nuclides):
11✔
640
            if nuclide == nuc.name:
11✔
641
                self.nuclides.remove(nuc)
11✔
642

643
    def remove_element(self, element):
11✔
644
        """Remove an element from the material
645

646
        .. versionadded:: 0.13.1
647

648
        Parameters
649
        ----------
650
        element : str
651
            Element to remove
652

653
        """
654
        cv.check_type('element', element, str)
11✔
655

656
        # If the Material contains the element, delete it
657
        for nuc in reversed(self.nuclides):
11✔
658
            element_name = re.split(r'\d+', nuc.name)[0]
11✔
659
            if element_name == element:
11✔
660
                self.nuclides.remove(nuc)
11✔
661

662
    def add_macroscopic(self, macroscopic: str):
11✔
663
        """Add a macroscopic to the material.  This will also set the
664
        density of the material to 1.0, unless it has been otherwise set,
665
        as a default for Macroscopic cross sections.
666

667
        Parameters
668
        ----------
669
        macroscopic : str
670
            Macroscopic to add
671

672
        """
673

674
        # Ensure no nuclides, elements, or sab are added since these would be
675
        # incompatible with macroscopics
676
        if self._nuclides or self._sab:
11✔
677
            msg = 'Unable to add a Macroscopic data set to Material ID="{}" ' \
11✔
678
                  'with a macroscopic value "{}" as an incompatible data ' \
679
                  'member (i.e., nuclide or S(a,b) table) ' \
680
                  'has already been added'.format(self._id, macroscopic)
681
            raise ValueError(msg)
11✔
682

683
        if not isinstance(macroscopic, str):
11✔
UNCOV
684
            msg = 'Unable to add a Macroscopic to Material ID="{}" with a ' \
×
685
                  'non-string value "{}"'.format(self._id, macroscopic)
UNCOV
686
            raise ValueError(msg)
×
687

688
        if self._macroscopic is None:
11✔
689
            self._macroscopic = macroscopic
11✔
690
        else:
691
            msg = 'Unable to add a Macroscopic to Material ID="{}". ' \
11✔
692
                  'Only one Macroscopic allowed per ' \
693
                  'Material.'.format(self._id)
694
            raise ValueError(msg)
11✔
695

696
        # Generally speaking, the density for a macroscopic object will
697
        # be 1.0. Therefore, lets set density to 1.0 so that the user
698
        # doesn't need to set it unless its needed.
699
        # Of course, if the user has already set a value of density,
700
        # then we will not override it.
701
        if self._density is None:
11✔
702
            self.set_density('macro', 1.0)
11✔
703

704
    def remove_macroscopic(self, macroscopic: str):
11✔
705
        """Remove a macroscopic from the material
706

707
        Parameters
708
        ----------
709
        macroscopic : str
710
            Macroscopic to remove
711

712
        """
713

714
        if not isinstance(macroscopic, str):
11✔
UNCOV
715
            msg = 'Unable to remove a Macroscopic "{}" in Material ID="{}" ' \
×
716
                  'since it is not a string'.format(self._id, macroscopic)
UNCOV
717
            raise ValueError(msg)
×
718

719
        # If the Material contains the Macroscopic, delete it
720
        if macroscopic == self._macroscopic:
11✔
721
            self._macroscopic = None
11✔
722

723
    def add_element(self, element: str, percent: float, percent_type: str = 'ao',
11✔
724
                    enrichment: float | None = None,
725
                    enrichment_target: str | None = None,
726
                    enrichment_type: str | None = None,
727
                    cross_sections: str | None = None):
728
        """Add a natural element to the material
729

730
        Parameters
731
        ----------
732
        element : str
733
            Element to add, e.g., 'Zr' or 'Zirconium'
734
        percent : float
735
            Atom or weight percent
736
        percent_type : {'ao', 'wo'}, optional
737
            'ao' for atom percent and 'wo' for weight percent. Defaults to atom
738
            percent.
739
        enrichment : float, optional
740
            Enrichment of an enrichment_target nuclide in percent (ao or wo).
741
            If enrichment_target is not supplied then it is enrichment for U235
742
            in weight percent. For example, input 4.95 for 4.95 weight percent
743
            enriched U.
744
            Default is None (natural composition).
745
        enrichment_target: str, optional
746
            Single nuclide name to enrich from a natural composition (e.g., 'O16')
747

748
            .. versionadded:: 0.12
749
        enrichment_type: {'ao', 'wo'}, optional
750
            'ao' for enrichment as atom percent and 'wo' for weight percent.
751
            Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment
752

753
            .. versionadded:: 0.12
754
        cross_sections : str, optional
755
            Location of cross_sections.xml file.
756

757
        Notes
758
        -----
759
        General enrichment procedure is allowed only for elements composed of
760
        two isotopes. If `enrichment_target` is given without `enrichment`
761
        natural composition is added to the material.
762

763
        """
764

765
        cv.check_type('nuclide', element, str)
11✔
766
        cv.check_type('percent', percent, Real)
11✔
767
        cv.check_greater_than('percent', percent, 0, equality=True)
11✔
768
        cv.check_value('percent type', percent_type, {'ao', 'wo'})
11✔
769

770
        # Make sure element name is just that
771
        if not element.isalpha():
11✔
UNCOV
772
            raise ValueError("Element name should be given by the "
×
773
                             "element's symbol or name, e.g., 'Zr', 'zirconium'")
774

775
        if self._ncrystal_cfg is not None:
11✔
UNCOV
776
            raise ValueError("Cannot add elements to NCrystal material")
×
777

778
        # Allow for element identifier to be given as a symbol or name
779
        if len(element) > 2:
11✔
780
            el = element.lower()
11✔
781
            element = openmc.data.ELEMENT_SYMBOL.get(el)
11✔
782
            if element is None:
11✔
783
                msg = f'Element name "{el}" not recognised'
11✔
784
                raise ValueError(msg)
11✔
785
        else:
786
            if element[0].islower():
11✔
787
                msg = f'Element name "{element}" should start with an uppercase letter'
11✔
788
                raise ValueError(msg)
11✔
789
            if len(element) == 2 and element[1].isupper():
11✔
790
                msg = f'Element name "{element}" should end with a lowercase letter'
11✔
791
                raise ValueError(msg)
11✔
792
            # skips the first entry of ATOMIC_SYMBOL which is n for neutron
793
            if element not in list(openmc.data.ATOMIC_SYMBOL.values())[1:]:
11✔
794
                msg = f'Element name "{element}" not recognised'
11✔
795
                raise ValueError(msg)
11✔
796

797
        if self._macroscopic is not None:
11✔
798
            msg = 'Unable to add an Element to Material ID="{}" as a ' \
11✔
799
                  'macroscopic data-set has already been added'.format(self._id)
800
            raise ValueError(msg)
11✔
801

802
        if enrichment is not None and enrichment_target is None:
11✔
803
            if not isinstance(enrichment, Real):
11✔
UNCOV
804
                msg = 'Unable to add an Element to Material ID="{}" with a ' \
×
805
                      'non-floating point enrichment value "{}"'\
806
                      .format(self._id, enrichment)
UNCOV
807
                raise ValueError(msg)
×
808

809
            elif element != 'U':
11✔
810
                msg = 'Unable to use enrichment for element {} which is not ' \
11✔
811
                      'uranium for Material ID="{}"'.format(element, self._id)
812
                raise ValueError(msg)
11✔
813

814
            # Check that the enrichment is in the valid range
815
            cv.check_less_than('enrichment', enrichment, 100./1.008)
11✔
816
            cv.check_greater_than('enrichment', enrichment, 0., equality=True)
11✔
817

818
            if enrichment > 5.0:
11✔
UNCOV
819
                msg = 'A uranium enrichment of {} was given for Material ID='\
×
820
                      '"{}". OpenMC assumes the U234/U235 mass ratio is '\
821
                      'constant at 0.008, which is only valid at low ' \
822
                      'enrichments. Consider setting the isotopic ' \
823
                      'composition manually for enrichments over 5%.'.\
824
                      format(enrichment, self._id)
UNCOV
825
                warnings.warn(msg)
×
826

827
        # Add naturally-occuring isotopes
828
        element = openmc.Element(element)
11✔
829
        for nuclide in element.expand(percent,
11✔
830
                                      percent_type,
831
                                      enrichment,
832
                                      enrichment_target,
833
                                      enrichment_type,
834
                                      cross_sections):
835
            self.add_nuclide(*nuclide)
11✔
836

837
    def add_elements_from_formula(self, formula: str, percent_type: str = 'ao',
11✔
838
                                  enrichment: float | None = None,
839
                                  enrichment_target: str | None = None,
840
                                  enrichment_type: str | None = None):
841
        """Add a elements from a chemical formula to the material.
842

843
        .. versionadded:: 0.12
844

845
        Parameters
846
        ----------
847
        formula : str
848
            Formula to add, e.g., 'C2O', 'C6H12O6', or (NH4)2SO4.
849
            Note this is case sensitive, elements must start with an uppercase
850
            character. Multiplier numbers must be integers.
851
        percent_type : {'ao', 'wo'}, optional
852
            'ao' for atom percent and 'wo' for weight percent. Defaults to atom
853
            percent.
854
        enrichment : float, optional
855
            Enrichment of an enrichment_target nuclide in percent (ao or wo).
856
            If enrichment_target is not supplied then it is enrichment for U235
857
            in weight percent. For example, input 4.95 for 4.95 weight percent
858
            enriched U. Default is None (natural composition).
859
        enrichment_target : str, optional
860
            Single nuclide name to enrich from a natural composition (e.g., 'O16')
861
        enrichment_type : {'ao', 'wo'}, optional
862
            'ao' for enrichment as atom percent and 'wo' for weight percent.
863
            Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment
864

865
        Notes
866
        -----
867
        General enrichment procedure is allowed only for elements composed of
868
        two isotopes. If `enrichment_target` is given without `enrichment`
869
        natural composition is added to the material.
870

871
        """
872
        cv.check_type('formula', formula, str)
11✔
873

874
        if '.' in formula:
11✔
875
            msg = 'Non-integer multiplier values are not accepted. The ' \
11✔
876
                  'input formula {} contains a "." character.'.format(formula)
877
            raise ValueError(msg)
11✔
878

879
        # Tokenizes the formula and check validity of tokens
880
        tokens = re.findall(r"([A-Z][a-z]*)(\d*)|(\()|(\))(\d*)", formula)
11✔
881
        for row in tokens:
11✔
882
            for token in row:
11✔
883
                if token.isalpha():
11✔
884
                    if token == "n" or token not in openmc.data.ATOMIC_NUMBER:
11✔
885
                        msg = f'Formula entry {token} not an element symbol.'
11✔
886
                        raise ValueError(msg)
11✔
887
                elif token not in ['(', ')', ''] and not token.isdigit():
11✔
UNCOV
888
                        msg = 'Formula must be made from a sequence of ' \
×
889
                              'element symbols, integers, and brackets. ' \
890
                              '{} is not an allowable entry.'.format(token)
UNCOV
891
                        raise ValueError(msg)
×
892

893
        # Checks that the number of opening and closing brackets are equal
894
        if formula.count('(') != formula.count(')'):
11✔
895
            msg = 'Number of opening and closing brackets is not equal ' \
11✔
896
                  'in the input formula {}.'.format(formula)
897
            raise ValueError(msg)
11✔
898

899
        # Checks that every part of the original formula has been tokenized
900
        for row in tokens:
11✔
901
            for token in row:
11✔
902
                formula = formula.replace(token, '', 1)
11✔
903
        if len(formula) != 0:
11✔
904
            msg = 'Part of formula was not successfully parsed as an ' \
11✔
905
                  'element symbol, bracket or integer. {} was not parsed.' \
906
                  .format(formula)
907
            raise ValueError(msg)
11✔
908

909
        # Works through the tokens building a stack
910
        mat_stack = [Counter()]
11✔
911
        for symbol, multi1, opening_bracket, closing_bracket, multi2 in tokens:
11✔
912
            if symbol:
11✔
913
                mat_stack[-1][symbol] += int(multi1 or 1)
11✔
914
            if opening_bracket:
11✔
915
                mat_stack.append(Counter())
11✔
916
            if closing_bracket:
11✔
917
                stack_top = mat_stack.pop()
11✔
918
                for symbol, value in stack_top.items():
11✔
919
                    mat_stack[-1][symbol] += int(multi2 or 1) * value
11✔
920

921
        # Normalizing percentages
922
        percents = mat_stack[0].values()
11✔
923
        norm_percents = [float(i) / sum(percents) for i in percents]
11✔
924
        elements = mat_stack[0].keys()
11✔
925

926
        # Adds each element and percent to the material
927
        for element, percent in zip(elements, norm_percents):
11✔
928
            if enrichment_target is not None and element == re.sub(r'\d+$', '', enrichment_target):
11✔
929
                self.add_element(element, percent, percent_type, enrichment,
11✔
930
                                 enrichment_target, enrichment_type)
931
            elif enrichment is not None and enrichment_target is None and element == 'U':
11✔
UNCOV
932
                self.add_element(element, percent, percent_type, enrichment)
×
933
            else:
934
                self.add_element(element, percent, percent_type)
11✔
935

936
    def add_s_alpha_beta(self, name: str, fraction: float = 1.0):
11✔
937
        r"""Add an :math:`S(\alpha,\beta)` table to the material
938

939
        Parameters
940
        ----------
941
        name : str
942
            Name of the :math:`S(\alpha,\beta)` table
943
        fraction : float
944
            The fraction of relevant nuclei that are affected by the
945
            :math:`S(\alpha,\beta)` table.  For example, if the material is a
946
            block of carbon that is 60% graphite and 40% amorphous then add a
947
            graphite :math:`S(\alpha,\beta)` table with fraction=0.6.
948

949
        """
950

951
        if self._macroscopic is not None:
11✔
UNCOV
952
            msg = 'Unable to add an S(a,b) table to Material ID="{}" as a ' \
×
953
                  'macroscopic data-set has already been added'.format(self._id)
UNCOV
954
            raise ValueError(msg)
×
955

956
        if not isinstance(name, str):
11✔
UNCOV
957
            msg = 'Unable to add an S(a,b) table to Material ID="{}" with a ' \
×
958
                        'non-string table name "{}"'.format(self._id, name)
UNCOV
959
            raise ValueError(msg)
×
960

961
        cv.check_type('S(a,b) fraction', fraction, Real)
11✔
962
        cv.check_greater_than('S(a,b) fraction', fraction, 0.0, True)
11✔
963
        cv.check_less_than('S(a,b) fraction', fraction, 1.0, True)
11✔
964
        self._sab.append((name, fraction))
11✔
965

966
    def make_isotropic_in_lab(self):
11✔
967
        self.isotropic = [x.name for x in self._nuclides]
11✔
968

969
    def get_elements(self) -> list[str]:
11✔
970
        """Returns all elements in the material
971

972
        .. versionadded:: 0.12
973

974
        Returns
975
        -------
976
        elements : list of str
977
            List of element names
978

979
        """
980

981
        return sorted({re.split(r'(\d+)', i)[0] for i in self.get_nuclides()})
11✔
982

983
    def get_nuclides(self, element: str | None = None) -> list[str]:
11✔
984
        """Returns a list of all nuclides in the material, if the element
985
        argument is specified then just nuclides of that element are returned.
986

987
        Parameters
988
        ----------
989
        element : str
990
            Specifies the element to match when searching through the nuclides
991

992
            .. versionadded:: 0.13.2
993

994
        Returns
995
        -------
996
        nuclides : list of str
997
            List of nuclide names
998
        """
999

1000
        matching_nuclides = []
11✔
1001
        if element:
11✔
1002
            for nuclide in self._nuclides:
11✔
1003
                if re.split(r'(\d+)', nuclide.name)[0] == element:
11✔
1004
                    if nuclide.name not in matching_nuclides:
11✔
1005
                        matching_nuclides.append(nuclide.name)
11✔
1006
        else:
1007
            for nuclide in self._nuclides:
11✔
1008
                if nuclide.name not in matching_nuclides:
11✔
1009
                    matching_nuclides.append(nuclide.name)
11✔
1010

1011
        return matching_nuclides
11✔
1012

1013
    def get_nuclide_densities(self) -> dict[str, tuple]:
11✔
1014
        """Returns all nuclides in the material and their densities
1015

1016
        Returns
1017
        -------
1018
        nuclides : dict
1019
            Dictionary whose keys are nuclide names and values are 3-tuples of
1020
            (nuclide, density percent, density percent type)
1021

1022
        """
1023

1024
        nuclides = {}
11✔
1025

1026
        for nuclide in self._nuclides:
11✔
1027
            nuclides[nuclide.name] = nuclide
11✔
1028

1029
        return nuclides
11✔
1030

1031
    def get_nuclide_atom_densities(self, nuclide: str | None = None) -> dict[str, float]:
11✔
1032
        """Returns one or all nuclides in the material and their atomic
1033
        densities in units of atom/b-cm
1034

1035
        .. versionchanged:: 0.13.1
1036
            The values in the dictionary were changed from a tuple containing
1037
            the nuclide name and the density to just the density.
1038

1039
        Parameters
1040
        ----------
1041
        nuclides : str, optional
1042
            Nuclide for which atom density is desired. If not specified, the
1043
            atom density for each nuclide in the material is given.
1044

1045
            .. versionadded:: 0.13.2
1046

1047
        Returns
1048
        -------
1049
        nuclides : dict
1050
            Dictionary whose keys are nuclide names and values are densities in
1051
            [atom/b-cm]
1052

1053
        """
1054

1055
        sum_density = False
11✔
1056
        if self.density_units == 'sum':
11✔
1057
            sum_density = True
11✔
1058
            density = 0.
11✔
1059
        elif self.density_units == 'macro':
11✔
UNCOV
1060
            density = self.density
×
1061
        elif self.density_units == 'g/cc' or self.density_units == 'g/cm3':
11✔
1062
            density = -self.density
11✔
1063
        elif self.density_units == 'kg/m3':
11✔
UNCOV
1064
            density = -0.001 * self.density
×
1065
        elif self.density_units == 'atom/b-cm':
11✔
1066
            density = self.density
11✔
1067
        elif self.density_units == 'atom/cm3' or self.density_units == 'atom/cc':
11✔
1068
            density = 1.e-24 * self.density
11✔
1069

1070
        # For ease of processing split out nuc, nuc_density,
1071
        # and nuc_density_type into separate arrays
1072
        nucs = []
11✔
1073
        nuc_densities = []
11✔
1074
        nuc_density_types = []
11✔
1075

1076
        for nuc in self.nuclides:
11✔
1077
            nucs.append(nuc.name)
11✔
1078
            nuc_densities.append(nuc.percent)
11✔
1079
            nuc_density_types.append(nuc.percent_type)
11✔
1080

1081
        nuc_densities = np.array(nuc_densities)
11✔
1082
        nuc_density_types = np.array(nuc_density_types)
11✔
1083

1084
        if sum_density:
11✔
1085
            density = np.sum(nuc_densities)
11✔
1086

1087
        percent_in_atom = np.all(nuc_density_types == 'ao')
11✔
1088
        density_in_atom = density > 0.
11✔
1089
        sum_percent = 0.
11✔
1090

1091
        # Convert the weight amounts to atomic amounts
1092
        if not percent_in_atom:
11✔
1093
            for n, nuc in enumerate(nucs):
11✔
1094
                nuc_densities[n] *= self.average_molar_mass / \
11✔
1095
                                    openmc.data.atomic_mass(nuc)
1096

1097
        # Now that we have the atomic amounts, lets finish calculating densities
1098
        sum_percent = np.sum(nuc_densities)
11✔
1099
        nuc_densities = nuc_densities / sum_percent
11✔
1100

1101
        # Convert the mass density to an atom density
1102
        if not density_in_atom:
11✔
1103
            density = -density / self.average_molar_mass * 1.e-24 \
11✔
1104
                      * openmc.data.AVOGADRO
1105

1106
        nuc_densities = density * nuc_densities
11✔
1107

1108
        nuclides = {}
11✔
1109
        for n, nuc in enumerate(nucs):
11✔
1110
            if nuclide is None or nuclide == nuc:
11✔
1111
                nuclides[nuc] = nuc_densities[n]
11✔
1112

1113
        return nuclides
11✔
1114

1115
    def get_element_atom_densities(self, element: str | None = None) -> dict[str, float]:
11✔
1116
        """Returns one or all elements in the material and their atomic
1117
        densities in units of atom/b-cm
1118

1119
        .. versionadded:: 0.15.1
1120

1121
        Parameters
1122
        ----------
1123
        element : str, optional
1124
            Element for which atom density is desired. If not specified, the
1125
            atom density for each element in the material is given.
1126

1127
        Returns
1128
        -------
1129
        elements : dict
1130
            Dictionary whose keys are element names and values are densities in
1131
            [atom/b-cm]
1132

1133
        """
1134
        if element is not None:
11✔
1135
            element = _get_element_symbol(element)
11✔
1136

1137
        nuc_densities = self.get_nuclide_atom_densities()
11✔
1138

1139
        # Initialize an empty dictionary for summed values
1140
        densities = {}
11✔
1141

1142
        # Accumulate densities for each nuclide
1143
        for nuclide, density in nuc_densities.items():
11✔
1144
            nuc_element = openmc.data.ATOMIC_SYMBOL[openmc.data.zam(nuclide)[0]]
11✔
1145
            if element is None or element == nuc_element:
11✔
1146
                if nuc_element not in densities:
11✔
1147
                    densities[nuc_element] = 0.0
11✔
1148
                densities[nuc_element] += float(density)
11✔
1149

1150
        # If specific element was requested, make sure it is present
1151
        if element is not None and element not in densities:
11✔
1152
                raise ValueError(f'Element {element} not found in material.')
11✔
1153

1154
        return densities
11✔
1155

1156

1157
    def get_activity(self, units: str = 'Bq/cm3', by_nuclide: bool = False,
11✔
1158
                     volume: float | None = None) -> dict[str, float] | float:
1159
        """Returns the activity of the material or of each nuclide within.
1160

1161
        .. versionadded:: 0.13.1
1162

1163
        Parameters
1164
        ----------
1165
        units : {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3', 'Ci', 'Ci/m3'}
1166
            Specifies the type of activity to return, options include total
1167
            activity [Bq,Ci], specific [Bq/g, Bq/kg] or volumetric activity
1168
            [Bq/cm3,Ci/m3]. Default is volumetric activity [Bq/cm3].
1169
        by_nuclide : bool
1170
            Specifies if the activity should be returned for the material as a
1171
            whole or per nuclide. Default is False.
1172
        volume : float, optional
1173
            Volume of the material. If not passed, defaults to using the
1174
            :attr:`Material.volume` attribute.
1175

1176
            .. versionadded:: 0.13.3
1177

1178
        Returns
1179
        -------
1180
        Union[dict, float]
1181
            If by_nuclide is True then a dictionary whose keys are nuclide
1182
            names and values are activity is returned. Otherwise the activity
1183
            of the material is returned as a float.
1184
        """
1185

1186
        cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3', 'Ci', 'Ci/m3'})
11✔
1187
        cv.check_type('by_nuclide', by_nuclide, bool)
11✔
1188

1189
        if volume is None:
11✔
1190
            volume = self.volume
11✔
1191

1192
        if units == 'Bq':
11✔
1193
            multiplier = volume
11✔
1194
        elif units == 'Bq/cm3':
11✔
1195
            multiplier = 1
11✔
1196
        elif units == 'Bq/g':
11✔
1197
            multiplier = 1.0 / self.get_mass_density()
11✔
1198
        elif units == 'Bq/kg':
11✔
1199
            multiplier = 1000.0 / self.get_mass_density()
11✔
1200
        elif units == 'Ci':
11✔
1201
            multiplier = volume / _BECQUEREL_PER_CURIE
11✔
1202
        elif units == 'Ci/m3':
11✔
1203
            multiplier = 1e6 / _BECQUEREL_PER_CURIE
11✔
1204

1205
        activity = {}
11✔
1206
        for nuclide, atoms_per_bcm in self.get_nuclide_atom_densities().items():
11✔
1207
            inv_seconds = openmc.data.decay_constant(nuclide)
11✔
1208
            activity[nuclide] = inv_seconds * 1e24 * atoms_per_bcm * multiplier
11✔
1209

1210
        return activity if by_nuclide else sum(activity.values())
11✔
1211

1212
    def get_decay_heat(self, units: str = 'W', by_nuclide: bool = False,
11✔
1213
                       volume: float | None = None) -> dict[str, float] | float:
1214
        """Returns the decay heat of the material or for each nuclide in the
1215
        material in units of [W], [W/g], [W/kg] or [W/cm3].
1216

1217
        .. versionadded:: 0.13.3
1218

1219
        Parameters
1220
        ----------
1221
        units : {'W', 'W/g', 'W/kg', 'W/cm3'}
1222
            Specifies the units of decay heat to return. Options include total
1223
            heat [W], specific [W/g, W/kg] or volumetric heat [W/cm3].
1224
            Default is total heat [W].
1225
        by_nuclide : bool
1226
            Specifies if the decay heat should be returned for the material as a
1227
            whole or per nuclide. Default is False.
1228
        volume : float, optional
1229
            Volume of the material. If not passed, defaults to using the
1230
            :attr:`Material.volume` attribute.
1231

1232
            .. versionadded:: 0.13.3
1233

1234
        Returns
1235
        -------
1236
        Union[dict, float]
1237
            If `by_nuclide` is True then a dictionary whose keys are nuclide
1238
            names and values are decay heat is returned. Otherwise the decay heat
1239
            of the material is returned as a float.
1240
        """
1241

1242
        cv.check_value('units', units, {'W', 'W/g', 'W/kg', 'W/cm3'})
11✔
1243
        cv.check_type('by_nuclide', by_nuclide, bool)
11✔
1244

1245
        if units == 'W':
11✔
1246
            multiplier = volume if volume is not None else self.volume
11✔
1247
        elif units == 'W/cm3':
11✔
1248
            multiplier = 1
11✔
1249
        elif units == 'W/g':
11✔
1250
            multiplier = 1.0 / self.get_mass_density()
11✔
1251
        elif units == 'W/kg':
11✔
1252
            multiplier = 1000.0 / self.get_mass_density()
11✔
1253

1254
        decayheat = {}
11✔
1255
        for nuclide, atoms_per_bcm in self.get_nuclide_atom_densities().items():
11✔
1256
            decay_erg = openmc.data.decay_energy(nuclide)
11✔
1257
            inv_seconds = openmc.data.decay_constant(nuclide)
11✔
1258
            decay_erg *= openmc.data.JOULE_PER_EV
11✔
1259
            decayheat[nuclide] = inv_seconds * decay_erg * 1e24 * atoms_per_bcm * multiplier
11✔
1260

1261
        return decayheat if by_nuclide else sum(decayheat.values())
11✔
1262

1263
    def get_nuclide_atoms(self, volume: float | None = None) -> dict[str, float]:
11✔
1264
        """Return number of atoms of each nuclide in the material
1265

1266
        .. versionadded:: 0.13.1
1267

1268
        Parameters
1269
        ----------
1270
        volume : float, optional
1271
            Volume of the material. If not passed, defaults to using the
1272
            :attr:`Material.volume` attribute.
1273

1274
            .. versionadded:: 0.13.3
1275

1276
        Returns
1277
        -------
1278
        dict
1279
            Dictionary whose keys are nuclide names and values are number of
1280
            atoms present in the material.
1281

1282
        """
1283
        if volume is None:
11✔
1284
            volume = self.volume
11✔
1285
        if volume is None:
11✔
UNCOV
1286
            raise ValueError("Volume must be set in order to determine atoms.")
×
1287
        atoms = {}
11✔
1288
        for nuclide, atom_per_bcm in self.get_nuclide_atom_densities().items():
11✔
1289
            atoms[nuclide] = 1.0e24 * atom_per_bcm * volume
11✔
1290
        return atoms
11✔
1291

1292
    def get_mass_density(self, nuclide: str | None = None) -> float:
11✔
1293
        """Return mass density of one or all nuclides
1294

1295
        Parameters
1296
        ----------
1297
        nuclides : str, optional
1298
            Nuclide for which density is desired. If not specified, the density
1299
            for the entire material is given.
1300

1301
        Returns
1302
        -------
1303
        float
1304
            Density of the nuclide/material in [g/cm^3]
1305

1306
        """
1307
        mass_density = 0.0
11✔
1308
        for nuc, atoms_per_bcm in self.get_nuclide_atom_densities(nuclide=nuclide).items():
11✔
1309
            density_i = 1e24 * atoms_per_bcm * openmc.data.atomic_mass(nuc) \
11✔
1310
                        / openmc.data.AVOGADRO
1311
            mass_density += density_i
11✔
1312
        return mass_density
11✔
1313

1314
    def get_mass(self, nuclide: str | None = None, volume: float | None = None) -> float:
11✔
1315
        """Return mass of one or all nuclides.
1316

1317
        Note that this method requires that the :attr:`Material.volume` has
1318
        already been set.
1319

1320
        Parameters
1321
        ----------
1322
        nuclides : str, optional
1323
            Nuclide for which mass is desired. If not specified, the density
1324
            for the entire material is given.
1325
        volume : float, optional
1326
            Volume of the material. If not passed, defaults to using the
1327
            :attr:`Material.volume` attribute.
1328

1329
            .. versionadded:: 0.13.3
1330

1331

1332
        Returns
1333
        -------
1334
        float
1335
            Mass of the nuclide/material in [g]
1336

1337
        """
1338
        if volume is None:
11✔
1339
            volume = self.volume
11✔
1340
        if volume is None:
11✔
UNCOV
1341
            raise ValueError("Volume must be set in order to determine mass.")
×
1342
        return volume*self.get_mass_density(nuclide)
11✔
1343

1344
    def waste_classification(self, metal: bool = False) -> str:
11✔
1345
        """Classify the material for near-surface waste disposal.
1346

1347
        This method determines a waste classification for the material based on
1348
        the NRC regulations (10 CFR 61.55). Note that the NRC regulations do not
1349
        consider many long-lived radionuclides relevant to fusion systems; for
1350
        fusion applications, it is recommended to calculate a waste disposal
1351
        rating based on limits by Fetter et al. using the
1352
        :meth:`~openmc.Material.waste_disposal_rating` method.
1353

1354
        Parameters
1355
        ----------
1356
        metal : bool, optional
1357
            Whether or not the material is in metal form.
1358

1359
        Returns
1360
        -------
1361
        str
1362
            The waste disposal classification, which can be "Class A", "Class
1363
            B", "Class C", or "GTCC" (greater than class C).
1364

1365
        """
1366
        return waste._waste_classification(self, metal=metal)
11✔
1367

1368
    def waste_disposal_rating(
11✔
1369
        self,
1370
        limits: str | dict[str, float] = 'Fetter',
1371
        metal: bool = False,
1372
        by_nuclide: bool = False,
1373
    ) -> float | dict[str, float]:
1374
        """Return the waste disposal rating for the material.
1375

1376
        This method returns a waste disposal rating for the material based on a
1377
        set of specific activity limits. The waste disposal rating is a single
1378
        number that represents the sum of the ratios of the specific activity
1379
        for each radionuclide in the material against a nuclide-specific limit.
1380
        A value less than 1.0 indicates that the material "meets" the limits
1381
        whereas a value greater than 1.0 exceeds the limits.
1382

1383
        Note that the limits for NRC do not consider many long-lived
1384
        radionuclides relevant to fusion systems. A paper by `Fetter et al.
1385
        <https://doi.org/10.1016/0920-3796(90)90104-E>`_ applies the NRC
1386
        methodology to calculate specific activity limits for an expanded set of
1387
        radionuclides.
1388

1389
        Parameters
1390
        ----------
1391
        limits : str or dict, optional
1392
            The name of a predefined set of specific activity limits or a
1393
            dictionary that contains specific activity limits for radionuclides,
1394
            where keys are nuclide names and values are activities in units of
1395
            [Ci/m3]. The predefined options are:
1396

1397
            - 'Fetter': Uses limits from Fetter et al. (1990)
1398
            - 'NRC_long': Uses the 10 CFR 61.55 limits for long-lived
1399
              radionuclides
1400
            - 'NRC_short_A': Uses the 10 CFR 61.55 class A limits for
1401
              short-lived radionuclides
1402
            - 'NRC_short_B': Uses the 10 CFR 61.55 class B limits for
1403
              short-lived radionuclides
1404
            - 'NRC_short_C': Uses the 10 CFR 61.55 class C limits for
1405
              short-lived radionuclides
1406
        metal : bool, optional
1407
            Whether or not the material is in metal form (only applicable for
1408
            NRC based limits)
1409
        by_nuclide : bool, optional
1410
            Whether to return the waste disposal rating for each nuclide in the
1411
            material. If True, a dictionary is returned where the keys are the
1412
            nuclide names and the values are the waste disposal ratings for each
1413
            nuclide. If False, a single float value is returned that represents
1414
            the overall waste disposal rating for the material.
1415

1416
        Returns
1417
        -------
1418
        float or dict
1419
            The waste disposal rating for the material or its constituent
1420
            nuclides.
1421

1422
        See also
1423
        --------
1424
        Material.waste_classification()
1425

1426
        """
1427
        return waste._waste_disposal_rating(self, limits, metal, by_nuclide)
11✔
1428

1429
    def clone(self, memo: dict | None = None) -> Material:
11✔
1430
        """Create a copy of this material with a new unique ID.
1431

1432
        Parameters
1433
        ----------
1434
        memo : dict or None
1435
            A nested dictionary of previously cloned objects. This parameter
1436
            is used internally and should not be specified by the user.
1437

1438
        Returns
1439
        -------
1440
        clone : openmc.Material
1441
            The clone of this material
1442

1443
        """
1444

1445
        if memo is None:
11✔
1446
            memo = {}
11✔
1447

1448
        # If no nemoize'd clone exists, instantiate one
1449
        if self not in memo:
11✔
1450
            # Temporarily remove paths -- this is done so that when the clone is
1451
            # made, it doesn't create a copy of the paths (which are specific to
1452
            # an instance)
1453
            paths = self._paths
11✔
1454
            self._paths = None
11✔
1455

1456
            clone = deepcopy(self)
11✔
1457
            clone.id = None
11✔
1458
            clone._num_instances = None
11✔
1459

1460
            # Restore paths on original instance
1461
            self._paths = paths
11✔
1462

1463
            # Memoize the clone
1464
            memo[self] = clone
11✔
1465

1466
        return memo[self]
11✔
1467

1468
    def _get_nuclide_xml(self, nuclide: NuclideTuple) -> ET.Element:
11✔
1469
        xml_element = ET.Element("nuclide")
11✔
1470
        xml_element.set("name", nuclide.name)
11✔
1471

1472
        # Prevent subnormal numbers from being written to XML, which causes an
1473
        # exception on the C++ side when calling std::stod
1474
        val = nuclide.percent
11✔
1475
        if abs(val) < _SMALLEST_NORMAL:
11✔
1476
            val = 0.0
11✔
1477

1478
        if nuclide.percent_type == 'ao':
11✔
1479
            xml_element.set("ao", str(val))
11✔
1480
        else:
1481
            xml_element.set("wo", str(val))
11✔
1482

1483
        return xml_element
11✔
1484

1485
    def _get_macroscopic_xml(self, macroscopic: str) -> ET.Element:
11✔
1486
        xml_element = ET.Element("macroscopic")
11✔
1487
        xml_element.set("name", macroscopic)
11✔
1488

1489
        return xml_element
11✔
1490

1491
    def _get_nuclides_xml(
11✔
1492
            self, nuclides: Iterable[NuclideTuple],
1493
            nuclides_to_ignore: Iterable[str] | None = None)-> list[ET.Element]:
1494
        xml_elements = []
11✔
1495

1496
        # Remove any nuclides to ignore from the XML export
1497
        if nuclides_to_ignore:
11✔
1498
            nuclides = [nuclide for nuclide in nuclides if nuclide.name not in nuclides_to_ignore]
11✔
1499

1500
        xml_elements = [self._get_nuclide_xml(nuclide) for nuclide in nuclides]
11✔
1501

1502
        return xml_elements
11✔
1503

1504
    def to_xml_element(
11✔
1505
            self, nuclides_to_ignore: Iterable[str] | None = None) -> ET.Element:
1506
        """Return XML representation of the material
1507

1508
        Parameters
1509
        ----------
1510
        nuclides_to_ignore : list of str
1511
            Nuclides to ignore when exporting to XML.
1512

1513
        Returns
1514
        -------
1515
        element : lxml.etree._Element
1516
            XML element containing material data
1517

1518
        """
1519

1520
        # Create Material XML element
1521
        element = ET.Element("material")
11✔
1522
        element.set("id", str(self._id))
11✔
1523

1524
        if len(self._name) > 0:
11✔
1525
            element.set("name", str(self._name))
11✔
1526

1527
        if self._depletable:
11✔
1528
            element.set("depletable", "true")
11✔
1529

1530
        if self._volume:
11✔
1531
            element.set("volume", str(self._volume))
11✔
1532

1533
        if self._ncrystal_cfg:
11✔
1534
            if self._sab:
11✔
UNCOV
1535
                raise ValueError("NCrystal materials are not compatible with S(a,b).")
×
1536
            if self._macroscopic is not None:
11✔
UNCOV
1537
                raise ValueError("NCrystal materials are not compatible with macroscopic cross sections.")
×
1538

1539
            element.set("cfg", str(self._ncrystal_cfg))
11✔
1540

1541
        # Create temperature XML subelement
1542
        if self.temperature is not None:
11✔
1543
            element.set("temperature", str(self.temperature))
11✔
1544

1545
        # Create density XML subelement
1546
        if self._density is not None or self._density_units == 'sum':
11✔
1547
            subelement = ET.SubElement(element, "density")
11✔
1548
            if self._density_units != 'sum':
11✔
1549
                subelement.set("value", str(self._density))
11✔
1550
            subelement.set("units", self._density_units)
11✔
1551
        else:
UNCOV
1552
            raise ValueError(f'Density has not been set for material {self.id}!')
×
1553

1554
        if self._macroscopic is None:
11✔
1555
            # Create nuclide XML subelements
1556
            subelements = self._get_nuclides_xml(self._nuclides,
11✔
1557
                                                 nuclides_to_ignore=nuclides_to_ignore)
1558
            for subelement in subelements:
11✔
1559
                element.append(subelement)
11✔
1560
        else:
1561
            # Create macroscopic XML subelements
1562
            subelement = self._get_macroscopic_xml(self._macroscopic)
11✔
1563
            element.append(subelement)
11✔
1564

1565
        if self._sab:
11✔
1566
            for sab in self._sab:
11✔
1567
                subelement = ET.SubElement(element, "sab")
11✔
1568
                subelement.set("name", sab[0])
11✔
1569
                if sab[1] != 1.0:
11✔
1570
                    subelement.set("fraction", str(sab[1]))
11✔
1571

1572
        if self._isotropic:
11✔
1573
            subelement = ET.SubElement(element, "isotropic")
11✔
1574
            subelement.text = ' '.join(self._isotropic)
11✔
1575

1576
        return element
11✔
1577

1578
    @classmethod
11✔
1579
    def mix_materials(cls, materials, fracs: Iterable[float],
11✔
1580
                      percent_type: str = 'ao', **kwargs) -> Material:
1581
        """Mix materials together based on atom, weight, or volume fractions
1582

1583
        .. versionadded:: 0.12
1584

1585
        Parameters
1586
        ----------
1587
        materials : Iterable of openmc.Material
1588
            Materials to combine
1589
        fracs : Iterable of float
1590
            Fractions of each material to be combined
1591
        percent_type : {'ao', 'wo', 'vo'}
1592
            Type of percentage, must be one of 'ao', 'wo', or 'vo', to signify atom
1593
            percent (molar percent), weight percent, or volume percent,
1594
            optional. Defaults to 'ao'
1595
        **kwargs
1596
            Keyword arguments passed to :class:`openmc.Material`
1597

1598
        Returns
1599
        -------
1600
        openmc.Material
1601
            Mixture of the materials
1602

1603
        """
1604

1605
        cv.check_type('materials', materials, Iterable, Material)
11✔
1606
        cv.check_type('fracs', fracs, Iterable, Real)
11✔
1607
        cv.check_value('percent type', percent_type, {'ao', 'wo', 'vo'})
11✔
1608

1609
        fracs = np.asarray(fracs)
11✔
1610
        void_frac = 1. - np.sum(fracs)
11✔
1611

1612
        # Warn that fractions don't add to 1, set remainder to void, or raise
1613
        # an error if percent_type isn't 'vo'
1614
        if not np.isclose(void_frac, 0.):
11✔
1615
            if percent_type in ('ao', 'wo'):
11✔
UNCOV
1616
                msg = ('A non-zero void fraction is not acceptable for '
×
1617
                       'percent_type: {}'.format(percent_type))
UNCOV
1618
                raise ValueError(msg)
×
1619
            else:
1620
                msg = ('Warning: sum of fractions do not add to 1, void '
11✔
1621
                       'fraction set to {}'.format(void_frac))
1622
                warnings.warn(msg)
11✔
1623

1624
        # Calculate appropriate weights which are how many cc's of each
1625
        # material are found in 1cc of the composite material
1626
        amms = np.asarray([mat.average_molar_mass for mat in materials])
11✔
1627
        mass_dens = np.asarray([mat.get_mass_density() for mat in materials])
11✔
1628
        if percent_type == 'ao':
11✔
1629
            wgts = fracs * amms / mass_dens
11✔
1630
            wgts /= np.sum(wgts)
11✔
1631
        elif percent_type == 'wo':
11✔
1632
            wgts = fracs / mass_dens
11✔
1633
            wgts /= np.sum(wgts)
11✔
1634
        elif percent_type == 'vo':
11✔
1635
            wgts = fracs
11✔
1636

1637
        # If any of the involved materials contain S(a,b) tables raise an error
1638
        sab_names = set(sab[0] for mat in materials for sab in mat._sab)
11✔
1639
        if sab_names:
11✔
UNCOV
1640
            msg = ('Currently we do not support mixing materials containing '
×
1641
                   'S(a,b) tables')
UNCOV
1642
            raise NotImplementedError(msg)
×
1643

1644
        # Add nuclide densities weighted by appropriate fractions
1645
        nuclides_per_cc = defaultdict(float)
11✔
1646
        mass_per_cc = defaultdict(float)
11✔
1647
        for mat, wgt in zip(materials, wgts):
11✔
1648
            for nuc, atoms_per_bcm in mat.get_nuclide_atom_densities().items():
11✔
1649
                nuc_per_cc = wgt*1.e24*atoms_per_bcm
11✔
1650
                nuclides_per_cc[nuc] += nuc_per_cc
11✔
1651
                mass_per_cc[nuc] += nuc_per_cc*openmc.data.atomic_mass(nuc) / \
11✔
1652
                                    openmc.data.AVOGADRO
1653

1654
        # Create the new material with the desired name
1655
        if "name" not in kwargs:
11✔
1656
            kwargs["name"] = '-'.join([f'{m.name}({f})' for m, f in
11✔
1657
                             zip(materials, fracs)])
1658

1659
        new_mat = cls(**kwargs)
11✔
1660

1661
        # Compute atom fractions of nuclides and add them to the new material
1662
        tot_nuclides_per_cc = np.sum([dens for dens in nuclides_per_cc.values()])
11✔
1663
        for nuc, atom_dens in nuclides_per_cc.items():
11✔
1664
            new_mat.add_nuclide(nuc, atom_dens/tot_nuclides_per_cc, 'ao')
11✔
1665

1666
        # Compute mass density for the new material and set it
1667
        new_density = np.sum([dens for dens in mass_per_cc.values()])
11✔
1668
        new_mat.set_density('g/cm3', new_density)
11✔
1669

1670
        # If any of the involved materials is depletable, the new material is
1671
        # depletable
1672
        new_mat.depletable = any(mat.depletable for mat in materials)
11✔
1673

1674
        return new_mat
11✔
1675

1676
    @classmethod
11✔
1677
    def from_xml_element(cls, elem: ET.Element) -> Material:
11✔
1678
        """Generate material from an XML element
1679

1680
        Parameters
1681
        ----------
1682
        elem : lxml.etree._Element
1683
            XML element
1684

1685
        Returns
1686
        -------
1687
        openmc.Material
1688
            Material generated from XML element
1689

1690
        """
1691
        mat_id = int(get_text(elem, 'id'))
11✔
1692

1693
        # Add NCrystal material from cfg string
1694
        cfg = get_text(elem, "cfg")
11✔
1695
        if cfg is not None:
11✔
1696
            return Material.from_ncrystal(cfg, material_id=mat_id)
11✔
1697

1698
        mat = cls(mat_id)
11✔
1699
        mat.name = get_text(elem, 'name')
11✔
1700

1701
        temperature = get_text(elem, "temperature")
11✔
1702
        if temperature is not None:
11✔
1703
            mat.temperature = float(temperature)
11✔
1704

1705
        volume = get_text(elem, "volume")
11✔
1706
        if volume is not None:
11✔
1707
            mat.volume = float(volume)
11✔
1708

1709
        # Get each nuclide
1710
        for nuclide in elem.findall('nuclide'):
11✔
1711
            name = get_text(nuclide, "name")
11✔
1712
            if 'ao' in nuclide.attrib:
11✔
1713
                mat.add_nuclide(name, float(nuclide.attrib['ao']))
11✔
1714
            elif 'wo' in nuclide.attrib:
11✔
1715
                mat.add_nuclide(name, float(nuclide.attrib['wo']), 'wo')
11✔
1716

1717
        # Get depletable attribute
1718
        depletable = get_text(elem, "depletable")
11✔
1719
        mat.depletable = depletable in ('true', '1')
11✔
1720

1721
        # Get each S(a,b) table
1722
        for sab in elem.findall('sab'):
11✔
1723
            fraction = float(get_text(sab, "fraction", 1.0))
11✔
1724
            name = get_text(sab, "name")
11✔
1725
            mat.add_s_alpha_beta(name, fraction)
11✔
1726

1727
        # Get total material density
1728
        density = elem.find('density')
11✔
1729
        units = get_text(density, "units")
11✔
1730
        if units == 'sum':
11✔
1731
            mat.set_density(units)
11✔
1732
        else:
1733
            value = float(get_text(density, 'value'))
11✔
1734
            mat.set_density(units, value)
11✔
1735

1736
        # Check for isotropic scattering nuclides
1737
        isotropic = get_elem_list(elem, "isotropic", str)
11✔
1738
        if isotropic is not None:
11✔
1739
            mat.isotropic = isotropic
11✔
1740

1741
        return mat
11✔
1742

1743
    def deplete(
11✔
1744
        self,
1745
        multigroup_flux: Sequence[float],
1746
        energy_group_structure: Sequence[float] | str,
1747
        timesteps: Sequence[float] | Sequence[tuple[float, str]],
1748
        source_rates: float | Sequence[float],
1749
        timestep_units: str = 's',
1750
        chain_file: cv.PathLike | "openmc.deplete.Chain" | None = None,
1751
        reactions: Sequence[str] | None = None,
1752
    ) -> list[openmc.Material]:
1753
        """Depletes that material, evolving the nuclide densities
1754

1755
        .. versionadded:: 0.15.3
1756

1757
        Parameters
1758
        ----------
1759
        multigroup_flux: Sequence[float]
1760
            Energy-dependent multigroup flux values, where each sublist corresponds
1761
            to a specific material. Will be normalized so that it sums to 1.
1762
        energy_group_structure : Sequence[float] | str
1763
            Energy group boundaries in [eV] or the name of the group structure.
1764
        timesteps : iterable of float or iterable of tuple
1765
            Array of timesteps. Note that values are not cumulative. The units are
1766
            specified by the `timestep_units` argument when `timesteps` is an
1767
            iterable of float. Alternatively, units can be specified for each step
1768
            by passing an iterable of (value, unit) tuples.
1769
        source_rates : float or iterable of float, optional
1770
            Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for
1771
            each interval in :attr:`timesteps`
1772
        timestep_units : {'s', 'min', 'h', 'd', 'a', 'MWd/kg'}
1773
            Units for values specified in the `timesteps` argument. 's' means
1774
            seconds, 'min' means minutes, 'h' means hours, 'a' means Julian years
1775
            and 'MWd/kg' indicates that the values are given in burnup (MW-d of
1776
            energy deposited per kilogram of initial heavy metal).
1777
        chain_file : PathLike or Chain
1778
            Path to the depletion chain XML file or instance of openmc.deplete.Chain.
1779
            Defaults to ``openmc.config['chain_file']``.
1780
        reactions : list of str, optional
1781
            Reactions to get cross sections for. If not specified, all neutron
1782
            reactions listed in the depletion chain file are used.
1783

1784
        Returns
1785
        -------
1786
        list of openmc.Material, one for each timestep
1787

1788
        """
1789

1790
        materials = openmc.Materials([self])
11✔
1791

1792
        depleted_materials_dict = materials.deplete(
11✔
1793
            multigroup_fluxes=[multigroup_flux],
1794
            energy_group_structures=[energy_group_structure],
1795
            timesteps=timesteps,
1796
            source_rates=source_rates,
1797
            timestep_units=timestep_units,
1798
            chain_file=chain_file,
1799
            reactions=reactions,
1800
        )
1801

1802
        return depleted_materials_dict[self.id]
11✔
1803

1804

1805
    def mean_free_path(self, energy: float) -> float:
11✔
1806
        """Calculate the mean free path of neutrons in the material at a given
1807
        energy.
1808

1809
        .. versionadded:: 0.15.3
1810

1811
        Parameters
1812
        ----------
1813
        energy : float
1814
            Neutron energy in eV
1815

1816
        Returns
1817
        -------
1818
        float
1819
            Mean free path in cm
1820

1821
        """
1822
        from openmc.plotter import _calculate_cexs_elem_mat
11✔
1823

1824
        energy_grid, cexs = _calculate_cexs_elem_mat(
11✔
1825
            this=self,
1826
            types=["total"],
1827
        )
1828
        total_cexs = cexs[0]
11✔
1829

1830
        interpolated_cexs = float(np.interp(energy, energy_grid, total_cexs))
11✔
1831

1832
        return 1.0 / interpolated_cexs
11✔
1833

1834

1835
class Materials(cv.CheckedList):
11✔
1836
    """Collection of Materials used for an OpenMC simulation.
1837

1838
    This class corresponds directly to the materials.xml input file. It can be
1839
    thought of as a normal Python list where each member is a :class:`Material`.
1840
    It behaves like a list as the following example demonstrates:
1841

1842
    >>> fuel = openmc.Material()
1843
    >>> clad = openmc.Material()
1844
    >>> water = openmc.Material()
1845
    >>> m = openmc.Materials([fuel])
1846
    >>> m.append(water)
1847
    >>> m += [clad]
1848

1849
    Parameters
1850
    ----------
1851
    materials : Iterable of openmc.Material
1852
        Materials to add to the collection
1853

1854
    Attributes
1855
    ----------
1856
    cross_sections : str or path-like
1857
        Indicates the path to an XML cross section listing file (usually named
1858
        cross_sections.xml). If it is not set, the
1859
        :envvar:`OPENMC_CROSS_SECTIONS` environment variable will be used for
1860
        continuous-energy calculations and :envvar:`OPENMC_MG_CROSS_SECTIONS`
1861
        will be used for multi-group calculations to find the path to the HDF5
1862
        cross section file.
1863

1864
    """
1865

1866
    def __init__(self, materials=None):
11✔
1867
        super().__init__(Material, 'materials collection')
11✔
1868
        self._cross_sections = None
11✔
1869

1870
        if materials is not None:
11✔
1871
            self += materials
11✔
1872

1873
    @property
11✔
1874
    def cross_sections(self) -> Path | None:
11✔
1875
        return self._cross_sections
11✔
1876

1877
    @cross_sections.setter
11✔
1878
    def cross_sections(self, cross_sections):
11✔
1879
        if cross_sections is not None:
11✔
1880
            self._cross_sections = input_path(cross_sections)
11✔
1881

1882
    def append(self, material):
11✔
1883
        """Append material to collection
1884

1885
        Parameters
1886
        ----------
1887
        material : openmc.Material
1888
            Material to append
1889

1890
        """
1891
        super().append(material)
11✔
1892

1893
    def insert(self, index: int, material):
11✔
1894
        """Insert material before index
1895

1896
        Parameters
1897
        ----------
1898
        index : int
1899
            Index in list
1900
        material : openmc.Material
1901
            Material to insert
1902

1903
        """
UNCOV
1904
        super().insert(index, material)
×
1905

1906
    def make_isotropic_in_lab(self):
11✔
1907
        for material in self:
11✔
1908
            material.make_isotropic_in_lab()
11✔
1909

1910
    def _write_xml(self, file, header=True, level=0, spaces_per_level=2,
11✔
1911
                   trailing_indent=True, nuclides_to_ignore=None):
1912
        """Writes XML content of the materials to an open file handle.
1913

1914
        Parameters
1915
        ----------
1916
        file : IOTextWrapper
1917
            Open file handle to write content into.
1918
        header : bool
1919
            Whether or not to write the XML header
1920
        level : int
1921
            Indentation level of materials element
1922
        spaces_per_level : int
1923
            Number of spaces per indentation
1924
        trailing_indentation : bool
1925
            Whether or not to write a trailing indentation for the materials element
1926
        nuclides_to_ignore : list of str
1927
            Nuclides to ignore when exporting to XML.
1928

1929
        """
1930
        indentation = level*spaces_per_level*' '
11✔
1931
        # Write the header and the opening tag for the root element.
1932
        if header:
11✔
1933
            file.write("<?xml version='1.0' encoding='utf-8'?>\n")
11✔
1934
        file.write(indentation+'<materials>\n')
11✔
1935

1936
        # Write the <cross_sections> element.
1937
        if self.cross_sections is not None:
11✔
1938
            element = ET.Element('cross_sections')
11✔
1939
            element.text = str(self.cross_sections)
11✔
1940
            clean_indentation(element, level=level+1)
11✔
1941
            element.tail = element.tail.strip(' ')
11✔
1942
            file.write((level+1)*spaces_per_level*' ')
11✔
1943
            file.write(ET.tostring(element, encoding="unicode"))
11✔
1944

1945
        # Write the <material> elements.
1946
        for material in sorted(set(self), key=lambda x: x.id):
11✔
1947
            element = material.to_xml_element(nuclides_to_ignore=nuclides_to_ignore)
11✔
1948
            clean_indentation(element, level=level+1)
11✔
1949
            element.tail = element.tail.strip(' ')
11✔
1950
            file.write((level+1)*spaces_per_level*' ')
11✔
1951
            file.write(ET.tostring(element, encoding="unicode"))
11✔
1952

1953
        # Write the closing tag for the root element.
1954
        file.write(indentation+'</materials>\n')
11✔
1955

1956
        # Write a trailing indentation for the next element
1957
        # at this level if needed
1958
        if trailing_indent:
11✔
1959
            file.write(indentation)
11✔
1960

1961
    def export_to_xml(self, path: PathLike = 'materials.xml',
11✔
1962
                      nuclides_to_ignore: Iterable[str] | None = None):
1963
        """Export material collection to an XML file.
1964

1965
        Parameters
1966
        ----------
1967
        path : str
1968
            Path to file to write. Defaults to 'materials.xml'.
1969
        nuclides_to_ignore : list of str
1970
            Nuclides to ignore when exporting to XML.
1971

1972
        """
1973
        # Check if path is a directory
1974
        p = Path(path)
11✔
1975
        if p.is_dir():
11✔
1976
            p /= 'materials.xml'
11✔
1977

1978
        # Write materials to the file one-at-a-time.  This significantly reduces
1979
        # memory demand over allocating a complete ElementTree and writing it in
1980
        # one go.
1981
        with open(str(p), 'w', encoding='utf-8',
11✔
1982
                  errors='xmlcharrefreplace') as fh:
1983
            self._write_xml(fh, nuclides_to_ignore=nuclides_to_ignore)
11✔
1984

1985
    @classmethod
11✔
1986
    def from_xml_element(cls, elem) -> Materials:
11✔
1987
        """Generate materials collection from XML file
1988

1989
        Parameters
1990
        ----------
1991
        elem : lxml.etree._Element
1992
            XML element
1993

1994
        Returns
1995
        -------
1996
        openmc.Materials
1997
            Materials collection
1998

1999
        """
2000
        # Generate each material
2001
        materials = cls()
11✔
2002
        for material in elem.findall('material'):
11✔
2003
            materials.append(Material.from_xml_element(material))
11✔
2004

2005
        # Check for cross sections settings
2006
        xs = get_text(elem, "cross_sections")
11✔
2007
        if xs is not None:
11✔
2008
            materials.cross_sections = xs
11✔
2009

2010
        return materials
11✔
2011

2012
    @classmethod
11✔
2013
    def from_xml(cls, path: PathLike = 'materials.xml') -> Materials:
11✔
2014
        """Generate materials collection from XML file
2015

2016
        Parameters
2017
        ----------
2018
        path : str
2019
            Path to materials XML file
2020

2021
        Returns
2022
        -------
2023
        openmc.Materials
2024
            Materials collection
2025

2026
        """
2027
        parser = ET.XMLParser(huge_tree=True)
11✔
2028
        tree = ET.parse(path, parser=parser)
11✔
2029
        root = tree.getroot()
11✔
2030

2031
        return cls.from_xml_element(root)
11✔
2032

2033

2034
    def deplete(
11✔
2035
        self,
2036
        multigroup_fluxes: Sequence[Sequence[float]],
2037
        energy_group_structures: Sequence[Sequence[float] | str],
2038
        timesteps: Sequence[float] | Sequence[tuple[float, str]],
2039
        source_rates: float | Sequence[float],
2040
        timestep_units: str = 's',
2041
        chain_file: cv.PathLike | "openmc.deplete.Chain" | None = None,
2042
        reactions: Sequence[str] | None = None,
2043
    ) -> Dict[int, list[openmc.Material]]:
2044
        """Depletes that material, evolving the nuclide densities
2045

2046
        .. versionadded:: 0.15.3
2047

2048
        Parameters
2049
        ----------
2050
        multigroup_fluxes: Sequence[Sequence[float]]
2051
            Energy-dependent multigroup flux values, where each sublist corresponds
2052
            to a specific material. Will be normalized so that it sums to 1.
2053
        energy_group_structures': Sequence[Sequence[float] | str]
2054
            Energy group boundaries in [eV] or the name of the group structure.
2055
        timesteps : iterable of float or iterable of tuple
2056
            Array of timesteps. Note that values are not cumulative. The units are
2057
            specified by the `timestep_units` argument when `timesteps` is an
2058
            iterable of float. Alternatively, units can be specified for each step
2059
            by passing an iterable of (value, unit) tuples.
2060
        source_rates : float or iterable of float, optional
2061
            Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for
2062
            each interval in :attr:`timesteps`
2063
        timestep_units : {'s', 'min', 'h', 'd', 'a', 'MWd/kg'}
2064
            Units for values specified in the `timesteps` argument. 's' means
2065
            seconds, 'min' means minutes, 'h' means hours, 'a' means Julian years
2066
            and 'MWd/kg' indicates that the values are given in burnup (MW-d of
2067
            energy deposited per kilogram of initial heavy metal).
2068
        chain_file : PathLike or Chain
2069
            Path to the depletion chain XML file or instance of openmc.deplete.Chain.
2070
            Defaults to ``openmc.config['chain_file']``.
2071
        reactions : list of str, optional
2072
            Reactions to get cross sections for. If not specified, all neutron
2073
            reactions listed in the depletion chain file are used.
2074

2075
        Returns
2076
        -------
2077
        list of openmc.Material, one for each timestep
2078

2079
        """
2080

2081
        import openmc.deplete
11✔
2082
        from .deplete.chain import _get_chain
11✔
2083

2084
        # setting all materials to be depletable
2085
        for mat in self:
11✔
2086
            mat.depletable = True
11✔
2087

2088
        chain = _get_chain(chain_file)
11✔
2089

2090
        # Create MicroXS objects for all materials
2091
        micros = []
11✔
2092
        fluxes = []
11✔
2093

2094
        with openmc.lib.TemporarySession():
11✔
2095
            for material, flux, energy in zip(
11✔
2096
                self, multigroup_fluxes, energy_group_structures
2097
            ):
2098
                temperature = material.temperature or 293.6
11✔
2099
                micro_xs = openmc.deplete.MicroXS.from_multigroup_flux(
11✔
2100
                    energies=energy,
2101
                    multigroup_flux=flux,
2102
                    chain_file=chain,
2103
                    temperature=temperature,
2104
                    reactions=reactions,
2105
                )
2106
                micros.append(micro_xs)
11✔
2107
                fluxes.append(material.volume)
11✔
2108

2109
        # Create a single operator for all materials
2110
        operator = openmc.deplete.IndependentOperator(
11✔
2111
            materials=self,
2112
            fluxes=fluxes,
2113
            micros=micros,
2114
            normalization_mode="source-rate",
2115
            chain_file=chain,
2116
        )
2117

2118
        integrator = openmc.deplete.PredictorIntegrator(
11✔
2119
            operator=operator,
2120
            timesteps=timesteps,
2121
            source_rates=source_rates,
2122
            timestep_units=timestep_units,
2123
        )
2124

2125
        with tempfile.TemporaryDirectory() as tmpdir:
11✔
2126
            # Run integrator
2127
            results_path = Path(tmpdir) / "depletion_results.h5"
11✔
2128
            integrator.integrate(path=results_path)
11✔
2129

2130
            # Load depletion results
2131
            results = openmc.deplete.Results(results_path)
11✔
2132

2133
            # For each material, get activated composition at each timestep
2134
            all_depleted_materials = {
11✔
2135
                material.id: [
2136
                    result.get_material(str(material.id))
2137
                    for result in results
2138
                ]
2139
                for material in self
2140
            }
2141

2142
        return all_depleted_materials
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