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

openmc-dev / openmc / 19578588634

21 Nov 2025 05:35PM UTC coverage: 82.056%. First build
19578588634

Pull #3649

github

web-flow
Merge ffcf4bdc8 into d217efa00
Pull Request #3649: Allowing material making from class constructor

16948 of 23517 branches covered (72.07%)

Branch coverage included in aggregate %.

14 of 16 new or added lines in 1 file covered. (87.5%)

54901 of 64044 relevant lines covered (85.72%)

42158742.79 hits per line

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

93.22
/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
    density : float, optional
64
        Density of the material (units defined separately)
65
    density_units : str
66
        Units used for `density`. Can be one of 'g/cm3', 'g/cc', 'kg/m3',
67
        'atom/b-cm', 'atom/cm3', 'sum', or 'macro'.  The 'macro' unit only
68
        applies in the case of a multi-group calculation.
69
    depletable : bool, optional
70
        Indicate whether the material is depletable. Defaults to False.
71
    nuclides : list of tuple, optional
72
        List in which each item is a namedtuple consisting of a nuclide string,
73
        the percent density, and the percent type ('ao' or 'wo'). The namedtuple
74
        has field names ``name``, ``percent``, and ``percent_type``.
75
    volume : float, optional
76
        Volume of the material in cm^3. This can either be set manually or
77
        calculated in a stochastic volume calculation and added via the
78
        :meth:`Material.add_volume_information` method.
79

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

123
        .. versionadded:: 0.13.3
124

125
    """
126

127
    next_id = 1
11✔
128
    used_ids = set()
11✔
129

130
    def __init__(
11✔
131
        self,
132
        material_id: int | None = None,
133
        name: str = "",
134
        temperature: float | None = None,
135
        density: float | None = None,
136
        density_units: str | None = None,
137
        depletable: bool | None = False,
138
        nuclides: list[tuple[str, float, str]] | None = None,
139
        volume: float | None = None,
140
    ):
141
        # Initialize class attributes
142
        self.id = material_id
11✔
143
        self.name = name
11✔
144
        self.temperature = temperature
11✔
145
        self._density = None
11✔
146
        self._density_units = 'sum'
11✔
147
        self._depletable = depletable
11✔
148
        self._paths = None
11✔
149
        self._num_instances = None
11✔
150
        self._volume = volume
11✔
151
        self._atoms = {}
11✔
152
        self._isotropic = []
11✔
153
        self._ncrystal_cfg = None
11✔
154

155
        # A list of tuples (nuclide, percent, percent type)
156
        self._nuclides = []
11✔
157

158
        # The single instance of Macroscopic data present in this material
159
        # (only one is allowed, hence this is different than _nuclides, etc)
160
        self._macroscopic = None
11✔
161

162
        # If specified, a list of table names
163
        self._sab = []
11✔
164

165
        # Set density if provided
166
        if (density is not None and density_units is None) or (
11✔
167
            density is None and density_units is not None
168
        ):
NEW
169
            raise ValueError(
×
170
                "Both density and density_units must be provided together."
171
            )
172
        elif density is not None and density_units is not None:
11✔
173
            self.set_density(density_units, density)
11✔
174

175
        # Add nuclides if provided
176
        if nuclides is not None:
11✔
177
            for entry in nuclides:
11✔
178
                if len(entry) == 2:
11✔
179
                    nuclide, percent = entry
11✔
180
                    percent_type = "ao"
11✔
181
                elif len(entry) == 3:
11✔
182
                    nuclide, percent, percent_type = entry
11✔
183
                else:
NEW
184
                    raise ValueError(
×
185
                        "Each nuclide tuple must have 2 or 3 elements: (nuclide, percent, [percent_type])"
186
                    )
187
                self.add_nuclide(nuclide, percent, percent_type)
11✔
188

189
    def __repr__(self) -> str:
11✔
190
        string = 'Material\n'
11✔
191
        string += '{: <16}=\t{}\n'.format('\tID', self._id)
11✔
192
        string += '{: <16}=\t{}\n'.format('\tName', self._name)
11✔
193
        string += '{: <16}=\t{}\n'.format('\tTemperature', self._temperature)
11✔
194

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

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

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

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

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

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

211
        for nuclide, percent, percent_type in self._nuclides:
11✔
212
            string += '{: <16}'.format('\t{}'.format(nuclide))
11✔
213
            string += f'=\t{percent: <12} [{percent_type}]\n'
11✔
214

215
        if self._macroscopic is not None:
11✔
216
            string += '{: <16}\n'.format('\tMacroscopic Data')
11✔
217
            string += '{: <16}'.format('\t{}'.format(self._macroscopic))
11✔
218

219
        return string
11✔
220

221
    @property
11✔
222
    def name(self) -> str | None:
11✔
223
        return self._name
11✔
224

225
    @name.setter
11✔
226
    def name(self, name: str | None):
11✔
227
        if name is not None:
11✔
228
            cv.check_type(f'name for Material ID="{self._id}"',
11✔
229
                          name, str)
230
            self._name = name
11✔
231
        else:
232
            self._name = ''
11✔
233

234
    @property
11✔
235
    def temperature(self) -> float | None:
11✔
236
        return self._temperature
11✔
237

238
    @temperature.setter
11✔
239
    def temperature(self, temperature: Real | None):
11✔
240
        cv.check_type(f'Temperature for Material ID="{self._id}"',
11✔
241
                      temperature, (Real, type(None)))
242
        self._temperature = temperature
11✔
243

244
    @property
11✔
245
    def density(self) -> float | None:
11✔
246
        return self._density
11✔
247

248
    @property
11✔
249
    def density_units(self) -> str:
11✔
250
        return self._density_units
11✔
251

252
    @property
11✔
253
    def depletable(self) -> bool:
11✔
254
        return self._depletable
11✔
255

256
    @depletable.setter
11✔
257
    def depletable(self, depletable: bool):
11✔
258
        cv.check_type(f'Depletable flag for Material ID="{self._id}"',
11✔
259
                      depletable, bool)
260
        self._depletable = depletable
11✔
261

262
    @property
11✔
263
    def paths(self) -> list[str]:
11✔
264
        if self._paths is None:
11✔
265
            raise ValueError('Material instance paths have not been determined. '
×
266
                             'Call the Geometry.determine_paths() method.')
267
        return self._paths
11✔
268

269
    @property
11✔
270
    def num_instances(self) -> int:
11✔
271
        if self._num_instances is None:
11✔
272
            raise ValueError(
×
273
                'Number of material instances have not been determined. Call '
274
                'the Geometry.determine_paths() method.')
275
        return self._num_instances
11✔
276

277
    @property
11✔
278
    def nuclides(self) -> list[namedtuple]:
11✔
279
        return self._nuclides
11✔
280

281
    @property
11✔
282
    def isotropic(self) -> list[str]:
11✔
283
        return self._isotropic
11✔
284

285
    @isotropic.setter
11✔
286
    def isotropic(self, isotropic: Iterable[str]):
11✔
287
        cv.check_iterable_type('Isotropic scattering nuclides', isotropic,
11✔
288
                               str)
289
        self._isotropic = list(isotropic)
11✔
290

291
    @property
11✔
292
    def average_molar_mass(self) -> float:
11✔
293
        # Using the sum of specified atomic or weight amounts as a basis, sum
294
        # the mass and moles of the material
295
        mass = 0.
11✔
296
        moles = 0.
11✔
297
        for nuc in self.nuclides:
11✔
298
            if nuc.percent_type == 'ao':
11✔
299
                mass += nuc.percent * openmc.data.atomic_mass(nuc.name)
11✔
300
                moles += nuc.percent
11✔
301
            else:
302
                moles += nuc.percent / openmc.data.atomic_mass(nuc.name)
11✔
303
                mass += nuc.percent
11✔
304

305
        # Compute and return the molar mass
306
        return mass / moles
11✔
307

308
    @property
11✔
309
    def volume(self) -> float | None:
11✔
310
        return self._volume
11✔
311

312
    @volume.setter
11✔
313
    def volume(self, volume: Real):
11✔
314
        if volume is not None:
11✔
315
            cv.check_type('material volume', volume, Real)
11✔
316
        self._volume = volume
11✔
317

318
    @property
11✔
319
    def ncrystal_cfg(self) -> str | None:
11✔
320
        return self._ncrystal_cfg
11✔
321

322
    @property
11✔
323
    def fissionable_mass(self) -> float:
11✔
324
        if self.volume is None:
11✔
325
            raise ValueError("Volume must be set in order to determine mass.")
×
326
        density = 0.0
11✔
327
        for nuc, atoms_per_bcm in self.get_nuclide_atom_densities().items():
11✔
328
            Z = openmc.data.zam(nuc)[0]
11✔
329
            if Z >= 90:
11✔
330
                density += 1e24 * atoms_per_bcm * openmc.data.atomic_mass(nuc) \
11✔
331
                           / openmc.data.AVOGADRO
332
        return density*self.volume
11✔
333

334
    @property
11✔
335
    def decay_photon_energy(self) -> Univariate | None:
11✔
336
        warnings.warn(
×
337
            "The 'decay_photon_energy' property has been replaced by the "
338
            "get_decay_photon_energy() method and will be removed in a future "
339
            "version.", FutureWarning)
340
        return self.get_decay_photon_energy(0.0)
×
341

342
    def get_decay_photon_energy(
11✔
343
        self,
344
        clip_tolerance: float = 1e-6,
345
        units: str = 'Bq',
346
        volume: float | None = None,
347
        exclude_nuclides: list[str] | None = None,
348
        include_nuclides: list[str] | None = None
349
    ) -> Univariate | None:
350
        r"""Return energy distribution of decay photons from unstable nuclides.
351

352
        .. versionadded:: 0.14.0
353

354
        Parameters
355
        ----------
356
        clip_tolerance : float
357
            Maximum fraction of :math:`\sum_i x_i p_i` for discrete distributions
358
            that will be discarded.
359
        units : {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3'}
360
            Specifies the units on the integral of the distribution.
361
        volume : float, optional
362
            Volume of the material. If not passed, defaults to using the
363
            :attr:`Material.volume` attribute.
364
        exclude_nuclides : list of str, optional
365
            Nuclides to exclude from the photon source calculation.
366
        include_nuclides : list of str, optional
367
            Nuclides to include in the photon source calculation. If specified,
368
            only these nuclides are used.
369

370
        Returns
371
        -------
372
        Univariate or None
373
            Decay photon energy distribution. The integral of this distribution is
374
            the total intensity of the photon source in the requested units.
375

376
        """
377
        cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3'})
11✔
378

379
        if exclude_nuclides is not None and include_nuclides is not None:
11✔
380
            raise ValueError("Cannot specify both exclude_nuclides and include_nuclides")
×
381

382
        if units == 'Bq':
11✔
383
            multiplier = volume if volume is not None else self.volume
11✔
384
            if multiplier is None:
11✔
385
                raise ValueError("volume must be specified if units='Bq'")
×
386
        elif units == 'Bq/cm3':
11✔
387
            multiplier = 1
11✔
388
        elif units == 'Bq/g':
11✔
389
            multiplier = 1.0 / self.get_mass_density()
11✔
390
        elif units == 'Bq/kg':
11✔
391
            multiplier = 1000.0 / self.get_mass_density()
11✔
392

393
        dists = []
11✔
394
        probs = []
11✔
395
        for nuc, atoms_per_bcm in self.get_nuclide_atom_densities().items():
11✔
396
            if exclude_nuclides is not None and nuc in exclude_nuclides:
11✔
397
                continue
×
398
            if include_nuclides is not None and nuc not in include_nuclides:
11✔
399
                continue
×
400

401
            source_per_atom = openmc.data.decay_photon_energy(nuc)
11✔
402
            if source_per_atom is not None and atoms_per_bcm > 0.0:
11✔
403
                dists.append(source_per_atom)
11✔
404
                probs.append(1e24 * atoms_per_bcm * multiplier)
11✔
405

406
        # If no photon sources, exit early
407
        if not dists:
11✔
408
            return None
11✔
409

410
        # Get combined distribution, clip low-intensity values in discrete spectra
411
        combined = openmc.data.combine_distributions(dists, probs)
11✔
412
        if isinstance(combined, (Discrete, Mixture)):
11✔
413
            combined.clip(clip_tolerance, inplace=True)
11✔
414

415
        # If clipping resulted in a single distribution within a mixture, pick
416
        # out that single distribution
417
        if isinstance(combined, Mixture) and len(combined.distribution) == 1:
11✔
418
            combined = combined.distribution[0]
×
419

420
        return combined
11✔
421

422
    @classmethod
11✔
423
    def from_hdf5(cls, group: h5py.Group) -> Material:
11✔
424
        """Create material from HDF5 group
425

426
        Parameters
427
        ----------
428
        group : h5py.Group
429
            Group in HDF5 file
430

431
        Returns
432
        -------
433
        openmc.Material
434
            Material instance
435

436
        """
437
        mat_id = int(group.name.split('/')[-1].lstrip('material '))
11✔
438

439
        name = group['name'][()].decode() if 'name' in group else ''
11✔
440
        density = group['atom_density'][()]
11✔
441
        if 'nuclide_densities' in group:
11✔
442
            nuc_densities = group['nuclide_densities'][()]
11✔
443

444
        # Create the Material
445
        material = cls(mat_id, name)
11✔
446
        material.depletable = bool(group.attrs['depletable'])
11✔
447
        if 'volume' in group.attrs:
11✔
448
            material.volume = group.attrs['volume']
11✔
449
        if "temperature" in group.attrs:
11✔
450
            material.temperature = group.attrs["temperature"]
11✔
451

452
        # Read the names of the S(a,b) tables for this Material and add them
453
        if 'sab_names' in group:
11✔
454
            sab_tables = group['sab_names'][()]
11✔
455
            for sab_table in sab_tables:
11✔
456
                name = sab_table.decode()
11✔
457
                material.add_s_alpha_beta(name)
11✔
458

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

462
        if 'nuclides' in group:
11✔
463
            nuclides = group['nuclides'][()]
11✔
464
            # Add all nuclides to the Material
465
            for fullname, density in zip(nuclides, nuc_densities):
11✔
466
                name = fullname.decode().strip()
11✔
467
                material.add_nuclide(name, percent=density, percent_type='ao')
11✔
468
        if 'macroscopics' in group:
11✔
469
            macroscopics = group['macroscopics'][()]
11✔
470
            # Add all macroscopics to the Material
471
            for fullname in macroscopics:
11✔
472
                name = fullname.decode().strip()
11✔
473
                material.add_macroscopic(name)
11✔
474

475
        return material
11✔
476

477
    @classmethod
11✔
478
    def from_ncrystal(cls, cfg, **kwargs) -> Material:
11✔
479
        """Create material from NCrystal configuration string.
480

481
        Density, temperature, and material composition, and (ultimately) thermal
482
        neutron scattering will be automatically be provided by NCrystal based
483
        on this string. The name and material_id parameters are simply passed on
484
        to the Material constructor.
485

486
        .. versionadded:: 0.13.3
487

488
        Parameters
489
        ----------
490
        cfg : str
491
            NCrystal configuration string
492
        **kwargs
493
            Keyword arguments passed to :class:`openmc.Material`
494

495
        Returns
496
        -------
497
        openmc.Material
498
            Material instance
499

500
        """
501

502
        try:
11✔
503
            import NCrystal
11✔
504
        except ModuleNotFoundError as e:
×
505
            raise RuntimeError('The .from_ncrystal method requires'
×
506
                               ' NCrystal to be installed.') from e
507
        nc_mat = NCrystal.createInfo(cfg)
11✔
508

509
        def openmc_natabund(Z):
11✔
510
            #nc_mat.getFlattenedComposition might need natural abundancies.
511
            #This call-back function is used so NCrystal can flatten composition
512
            #using OpenMC's natural abundancies. In practice this function will
513
            #only get invoked in the unlikely case where a material is specified
514
            #by referring both to natural elements and specific isotopes of the
515
            #same element.
516
            elem_name = openmc.data.ATOMIC_SYMBOL[Z]
×
517
            return [
×
518
                (int(iso_name[len(elem_name):]), abund)
519
                for iso_name, abund in openmc.data.isotopes(elem_name)
520
            ]
521

522
        flat_compos = nc_mat.getFlattenedComposition(
11✔
523
            preferNaturalElements=True, naturalAbundProvider=openmc_natabund)
524

525
        # Create the Material
526
        material = cls(temperature=nc_mat.getTemperature(), **kwargs)
11✔
527

528
        for Z, A_vals in flat_compos:
11✔
529
            elemname = openmc.data.ATOMIC_SYMBOL[Z]
11✔
530
            for A, frac in A_vals:
11✔
531
                if A:
11✔
532
                    material.add_nuclide(f'{elemname}{A}', frac)
×
533
                else:
534
                    material.add_element(elemname, frac)
11✔
535

536
        material.set_density('g/cm3', nc_mat.getDensity())
11✔
537
        material._ncrystal_cfg = NCrystal.normaliseCfg(cfg)
11✔
538

539
        return material
11✔
540

541
    def add_volume_information(self, volume_calc):
11✔
542
        """Add volume information to a material.
543

544
        Parameters
545
        ----------
546
        volume_calc : openmc.VolumeCalculation
547
            Results from a stochastic volume calculation
548

549
        """
550
        if volume_calc.domain_type == 'material':
11✔
551
            if self.id in volume_calc.volumes:
11✔
552
                self._volume = volume_calc.volumes[self.id].n
11✔
553
                self._atoms = volume_calc.atoms[self.id]
11✔
554
            else:
555
                raise ValueError('No volume information found for material ID={}.'
×
556
                                 .format(self.id))
557
        else:
558
            raise ValueError(f'No volume information found for material ID={self.id}.')
×
559

560
    def set_density(self, units: str, density: float | None = None):
11✔
561
        """Set the density of the material
562

563
        Parameters
564
        ----------
565
        units : {'g/cm3', 'g/cc', 'kg/m3', 'atom/b-cm', 'atom/cm3', 'sum', 'macro'}
566
            Physical units of density.
567
        density : float, optional
568
            Value of the density. Must be specified unless units is given as
569
            'sum'.
570

571
        """
572

573
        cv.check_value('density units', units, DENSITY_UNITS)
11✔
574
        self._density_units = units
11✔
575

576
        if units == 'sum':
11✔
577
            if density is not None:
11✔
578
                msg = 'Density "{}" for Material ID="{}" is ignored ' \
×
579
                      'because the unit is "sum"'.format(density, self.id)
580
                warnings.warn(msg)
×
581
        else:
582
            if density is None:
11✔
583
                msg = 'Unable to set the density for Material ID="{}" ' \
×
584
                      'because a density value must be given when not using ' \
585
                      '"sum" unit'.format(self.id)
586
                raise ValueError(msg)
×
587

588
            cv.check_type(f'the density for Material ID="{self.id}"',
11✔
589
                          density, Real)
590
            self._density = density
11✔
591

592
    def add_nuclide(self, nuclide: str, percent: float, percent_type: str = 'ao'):
11✔
593
        """Add a nuclide to the material
594

595
        Parameters
596
        ----------
597
        nuclide : str
598
            Nuclide to add, e.g., 'Mo95'
599
        percent : float
600
            Atom or weight percent
601
        percent_type : {'ao', 'wo'}
602
            'ao' for atom percent and 'wo' for weight percent
603

604
        """
605
        cv.check_type('nuclide', nuclide, str)
11✔
606
        cv.check_type('percent', percent, Real)
11✔
607
        cv.check_value('percent type', percent_type, {'ao', 'wo'})
11✔
608
        cv.check_greater_than('percent', percent, 0, equality=True)
11✔
609

610
        if self._macroscopic is not None:
11✔
611
            msg = 'Unable to add a Nuclide to Material ID="{}" as a ' \
11✔
612
                  'macroscopic data-set has already been added'.format(self._id)
613
            raise ValueError(msg)
11✔
614

615
        if self._ncrystal_cfg is not None:
11✔
616
            raise ValueError("Cannot add nuclides to NCrystal material")
×
617

618
        # If nuclide name doesn't look valid, give a warning
619
        try:
11✔
620
            Z, _, _ = openmc.data.zam(nuclide)
11✔
621
        except ValueError as e:
11✔
622
            warnings.warn(str(e))
11✔
623
        else:
624
            # For actinides, have the material be depletable by default
625
            if Z >= 89:
11✔
626
                self.depletable = True
11✔
627

628
        self._nuclides.append(NuclideTuple(nuclide, percent, percent_type))
11✔
629

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

633
        .. versionadded:: 0.13.1
634

635
        Parameters
636
        ----------
637
        components : dict of str to float or dict
638
            Dictionary mapping element or nuclide names to their atom or weight
639
            percent. To specify enrichment of an element, the entry of
640
            ``components`` for that element must instead be a dictionary
641
            containing the keyword arguments as well as a value for
642
            ``'percent'``
643
        percent_type : {'ao', 'wo'}
644
            'ao' for atom percent and 'wo' for weight percent
645

646
        Examples
647
        --------
648
        >>> mat = openmc.Material()
649
        >>> components  = {'Li': {'percent': 1.0,
650
        >>>                       'enrichment': 60.0,
651
        >>>                       'enrichment_target': 'Li7'},
652
        >>>                'Fl': 1.0,
653
        >>>                'Be6': 0.5}
654
        >>> mat.add_components(components)
655

656
        """
657

658
        for component, params in components.items():
11✔
659
            cv.check_type('component', component, str)
11✔
660
            if isinstance(params, Real):
11✔
661
                params = {'percent': params}
11✔
662

663
            else:
664
                cv.check_type('params', params, dict)
11✔
665
                if 'percent' not in params:
11✔
666
                    raise ValueError("An entry in the dictionary does not have "
×
667
                                     "a required key: 'percent'")
668

669
            params['percent_type'] = percent_type
11✔
670

671
            # check if nuclide
672
            if not component.isalpha():
11✔
673
                self.add_nuclide(component, **params)
11✔
674
            else:
675
                self.add_element(component, **params)
11✔
676

677
    def remove_nuclide(self, nuclide: str):
11✔
678
        """Remove a nuclide from the material
679

680
        Parameters
681
        ----------
682
        nuclide : str
683
            Nuclide to remove
684

685
        """
686
        cv.check_type('nuclide', nuclide, str)
11✔
687

688
        # If the Material contains the Nuclide, delete it
689
        for nuc in reversed(self.nuclides):
11✔
690
            if nuclide == nuc.name:
11✔
691
                self.nuclides.remove(nuc)
11✔
692

693
    def remove_element(self, element):
11✔
694
        """Remove an element from the material
695

696
        .. versionadded:: 0.13.1
697

698
        Parameters
699
        ----------
700
        element : str
701
            Element to remove
702

703
        """
704
        cv.check_type('element', element, str)
11✔
705

706
        # If the Material contains the element, delete it
707
        for nuc in reversed(self.nuclides):
11✔
708
            element_name = re.split(r'\d+', nuc.name)[0]
11✔
709
            if element_name == element:
11✔
710
                self.nuclides.remove(nuc)
11✔
711

712
    def add_macroscopic(self, macroscopic: str):
11✔
713
        """Add a macroscopic to the material.  This will also set the
714
        density of the material to 1.0, unless it has been otherwise set,
715
        as a default for Macroscopic cross sections.
716

717
        Parameters
718
        ----------
719
        macroscopic : str
720
            Macroscopic to add
721

722
        """
723

724
        # Ensure no nuclides, elements, or sab are added since these would be
725
        # incompatible with macroscopics
726
        if self._nuclides or self._sab:
11✔
727
            msg = 'Unable to add a Macroscopic data set to Material ID="{}" ' \
11✔
728
                  'with a macroscopic value "{}" as an incompatible data ' \
729
                  'member (i.e., nuclide or S(a,b) table) ' \
730
                  'has already been added'.format(self._id, macroscopic)
731
            raise ValueError(msg)
11✔
732

733
        if not isinstance(macroscopic, str):
11✔
734
            msg = 'Unable to add a Macroscopic to Material ID="{}" with a ' \
×
735
                  'non-string value "{}"'.format(self._id, macroscopic)
736
            raise ValueError(msg)
×
737

738
        if self._macroscopic is None:
11✔
739
            self._macroscopic = macroscopic
11✔
740
        else:
741
            msg = 'Unable to add a Macroscopic to Material ID="{}". ' \
11✔
742
                  'Only one Macroscopic allowed per ' \
743
                  'Material.'.format(self._id)
744
            raise ValueError(msg)
11✔
745

746
        # Generally speaking, the density for a macroscopic object will
747
        # be 1.0. Therefore, lets set density to 1.0 so that the user
748
        # doesn't need to set it unless its needed.
749
        # Of course, if the user has already set a value of density,
750
        # then we will not override it.
751
        if self._density is None:
11✔
752
            self.set_density('macro', 1.0)
11✔
753

754
    def remove_macroscopic(self, macroscopic: str):
11✔
755
        """Remove a macroscopic from the material
756

757
        Parameters
758
        ----------
759
        macroscopic : str
760
            Macroscopic to remove
761

762
        """
763

764
        if not isinstance(macroscopic, str):
11✔
765
            msg = 'Unable to remove a Macroscopic "{}" in Material ID="{}" ' \
×
766
                  'since it is not a string'.format(self._id, macroscopic)
767
            raise ValueError(msg)
×
768

769
        # If the Material contains the Macroscopic, delete it
770
        if macroscopic == self._macroscopic:
11✔
771
            self._macroscopic = None
11✔
772

773
    def add_element(self, element: str, percent: float, percent_type: str = 'ao',
11✔
774
                    enrichment: float | None = None,
775
                    enrichment_target: str | None = None,
776
                    enrichment_type: str | None = None,
777
                    cross_sections: str | None = None):
778
        """Add a natural element to the material
779

780
        Parameters
781
        ----------
782
        element : str
783
            Element to add, e.g., 'Zr' or 'Zirconium'
784
        percent : float
785
            Atom or weight percent
786
        percent_type : {'ao', 'wo'}, optional
787
            'ao' for atom percent and 'wo' for weight percent. Defaults to atom
788
            percent.
789
        enrichment : float, optional
790
            Enrichment of an enrichment_target nuclide in percent (ao or wo).
791
            If enrichment_target is not supplied then it is enrichment for U235
792
            in weight percent. For example, input 4.95 for 4.95 weight percent
793
            enriched U.
794
            Default is None (natural composition).
795
        enrichment_target: str, optional
796
            Single nuclide name to enrich from a natural composition (e.g., 'O16')
797

798
            .. versionadded:: 0.12
799
        enrichment_type: {'ao', 'wo'}, optional
800
            'ao' for enrichment as atom percent and 'wo' for weight percent.
801
            Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment
802

803
            .. versionadded:: 0.12
804
        cross_sections : str, optional
805
            Location of cross_sections.xml file.
806

807
        Notes
808
        -----
809
        General enrichment procedure is allowed only for elements composed of
810
        two isotopes. If `enrichment_target` is given without `enrichment`
811
        natural composition is added to the material.
812

813
        """
814

815
        cv.check_type('nuclide', element, str)
11✔
816
        cv.check_type('percent', percent, Real)
11✔
817
        cv.check_greater_than('percent', percent, 0, equality=True)
11✔
818
        cv.check_value('percent type', percent_type, {'ao', 'wo'})
11✔
819

820
        # Make sure element name is just that
821
        if not element.isalpha():
11✔
822
            raise ValueError("Element name should be given by the "
×
823
                             "element's symbol or name, e.g., 'Zr', 'zirconium'")
824

825
        if self._ncrystal_cfg is not None:
11✔
826
            raise ValueError("Cannot add elements to NCrystal material")
×
827

828
        # Allow for element identifier to be given as a symbol or name
829
        if len(element) > 2:
11✔
830
            el = element.lower()
11✔
831
            element = openmc.data.ELEMENT_SYMBOL.get(el)
11✔
832
            if element is None:
11✔
833
                msg = f'Element name "{el}" not recognised'
11✔
834
                raise ValueError(msg)
11✔
835
        else:
836
            if element[0].islower():
11✔
837
                msg = f'Element name "{element}" should start with an uppercase letter'
11✔
838
                raise ValueError(msg)
11✔
839
            if len(element) == 2 and element[1].isupper():
11✔
840
                msg = f'Element name "{element}" should end with a lowercase letter'
11✔
841
                raise ValueError(msg)
11✔
842
            # skips the first entry of ATOMIC_SYMBOL which is n for neutron
843
            if element not in list(openmc.data.ATOMIC_SYMBOL.values())[1:]:
11✔
844
                msg = f'Element name "{element}" not recognised'
11✔
845
                raise ValueError(msg)
11✔
846

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

852
        if enrichment is not None and enrichment_target is None:
11✔
853
            if not isinstance(enrichment, Real):
11✔
854
                msg = 'Unable to add an Element to Material ID="{}" with a ' \
×
855
                      'non-floating point enrichment value "{}"'\
856
                      .format(self._id, enrichment)
857
                raise ValueError(msg)
×
858

859
            elif element != 'U':
11✔
860
                msg = 'Unable to use enrichment for element {} which is not ' \
11✔
861
                      'uranium for Material ID="{}"'.format(element, self._id)
862
                raise ValueError(msg)
11✔
863

864
            # Check that the enrichment is in the valid range
865
            cv.check_less_than('enrichment', enrichment, 100./1.008)
11✔
866
            cv.check_greater_than('enrichment', enrichment, 0., equality=True)
11✔
867

868
            if enrichment > 5.0:
11✔
869
                msg = 'A uranium enrichment of {} was given for Material ID='\
×
870
                      '"{}". OpenMC assumes the U234/U235 mass ratio is '\
871
                      'constant at 0.008, which is only valid at low ' \
872
                      'enrichments. Consider setting the isotopic ' \
873
                      'composition manually for enrichments over 5%.'.\
874
                      format(enrichment, self._id)
875
                warnings.warn(msg)
×
876

877
        # Add naturally-occuring isotopes
878
        element = openmc.Element(element)
11✔
879
        for nuclide in element.expand(percent,
11✔
880
                                      percent_type,
881
                                      enrichment,
882
                                      enrichment_target,
883
                                      enrichment_type,
884
                                      cross_sections):
885
            self.add_nuclide(*nuclide)
11✔
886

887
    def add_elements_from_formula(self, formula: str, percent_type: str = 'ao',
11✔
888
                                  enrichment: float | None = None,
889
                                  enrichment_target: str | None = None,
890
                                  enrichment_type: str | None = None):
891
        """Add a elements from a chemical formula to the material.
892

893
        .. versionadded:: 0.12
894

895
        Parameters
896
        ----------
897
        formula : str
898
            Formula to add, e.g., 'C2O', 'C6H12O6', or (NH4)2SO4.
899
            Note this is case sensitive, elements must start with an uppercase
900
            character. Multiplier numbers must be integers.
901
        percent_type : {'ao', 'wo'}, optional
902
            'ao' for atom percent and 'wo' for weight percent. Defaults to atom
903
            percent.
904
        enrichment : float, optional
905
            Enrichment of an enrichment_target nuclide in percent (ao or wo).
906
            If enrichment_target is not supplied then it is enrichment for U235
907
            in weight percent. For example, input 4.95 for 4.95 weight percent
908
            enriched U. Default is None (natural composition).
909
        enrichment_target : str, optional
910
            Single nuclide name to enrich from a natural composition (e.g., 'O16')
911
        enrichment_type : {'ao', 'wo'}, optional
912
            'ao' for enrichment as atom percent and 'wo' for weight percent.
913
            Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment
914

915
        Notes
916
        -----
917
        General enrichment procedure is allowed only for elements composed of
918
        two isotopes. If `enrichment_target` is given without `enrichment`
919
        natural composition is added to the material.
920

921
        """
922
        cv.check_type('formula', formula, str)
11✔
923

924
        if '.' in formula:
11✔
925
            msg = 'Non-integer multiplier values are not accepted. The ' \
11✔
926
                  'input formula {} contains a "." character.'.format(formula)
927
            raise ValueError(msg)
11✔
928

929
        # Tokenizes the formula and check validity of tokens
930
        tokens = re.findall(r"([A-Z][a-z]*)(\d*)|(\()|(\))(\d*)", formula)
11✔
931
        for row in tokens:
11✔
932
            for token in row:
11✔
933
                if token.isalpha():
11✔
934
                    if token == "n" or token not in openmc.data.ATOMIC_NUMBER:
11✔
935
                        msg = f'Formula entry {token} not an element symbol.'
11✔
936
                        raise ValueError(msg)
11✔
937
                elif token not in ['(', ')', ''] and not token.isdigit():
11✔
938
                        msg = 'Formula must be made from a sequence of ' \
×
939
                              'element symbols, integers, and brackets. ' \
940
                              '{} is not an allowable entry.'.format(token)
941
                        raise ValueError(msg)
×
942

943
        # Checks that the number of opening and closing brackets are equal
944
        if formula.count('(') != formula.count(')'):
11✔
945
            msg = 'Number of opening and closing brackets is not equal ' \
11✔
946
                  'in the input formula {}.'.format(formula)
947
            raise ValueError(msg)
11✔
948

949
        # Checks that every part of the original formula has been tokenized
950
        for row in tokens:
11✔
951
            for token in row:
11✔
952
                formula = formula.replace(token, '', 1)
11✔
953
        if len(formula) != 0:
11✔
954
            msg = 'Part of formula was not successfully parsed as an ' \
11✔
955
                  'element symbol, bracket or integer. {} was not parsed.' \
956
                  .format(formula)
957
            raise ValueError(msg)
11✔
958

959
        # Works through the tokens building a stack
960
        mat_stack = [Counter()]
11✔
961
        for symbol, multi1, opening_bracket, closing_bracket, multi2 in tokens:
11✔
962
            if symbol:
11✔
963
                mat_stack[-1][symbol] += int(multi1 or 1)
11✔
964
            if opening_bracket:
11✔
965
                mat_stack.append(Counter())
11✔
966
            if closing_bracket:
11✔
967
                stack_top = mat_stack.pop()
11✔
968
                for symbol, value in stack_top.items():
11✔
969
                    mat_stack[-1][symbol] += int(multi2 or 1) * value
11✔
970

971
        # Normalizing percentages
972
        percents = mat_stack[0].values()
11✔
973
        norm_percents = [float(i) / sum(percents) for i in percents]
11✔
974
        elements = mat_stack[0].keys()
11✔
975

976
        # Adds each element and percent to the material
977
        for element, percent in zip(elements, norm_percents):
11✔
978
            if enrichment_target is not None and element == re.sub(r'\d+$', '', enrichment_target):
11✔
979
                self.add_element(element, percent, percent_type, enrichment,
11✔
980
                                 enrichment_target, enrichment_type)
981
            elif enrichment is not None and enrichment_target is None and element == 'U':
11✔
982
                self.add_element(element, percent, percent_type, enrichment)
×
983
            else:
984
                self.add_element(element, percent, percent_type)
11✔
985

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

989
        Parameters
990
        ----------
991
        name : str
992
            Name of the :math:`S(\alpha,\beta)` table
993
        fraction : float
994
            The fraction of relevant nuclei that are affected by the
995
            :math:`S(\alpha,\beta)` table.  For example, if the material is a
996
            block of carbon that is 60% graphite and 40% amorphous then add a
997
            graphite :math:`S(\alpha,\beta)` table with fraction=0.6.
998

999
        """
1000

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

1006
        if not isinstance(name, str):
11✔
1007
            msg = 'Unable to add an S(a,b) table to Material ID="{}" with a ' \
×
1008
                        'non-string table name "{}"'.format(self._id, name)
1009
            raise ValueError(msg)
×
1010

1011
        cv.check_type('S(a,b) fraction', fraction, Real)
11✔
1012
        cv.check_greater_than('S(a,b) fraction', fraction, 0.0, True)
11✔
1013
        cv.check_less_than('S(a,b) fraction', fraction, 1.0, True)
11✔
1014
        self._sab.append((name, fraction))
11✔
1015

1016
    def make_isotropic_in_lab(self):
11✔
1017
        self.isotropic = [x.name for x in self._nuclides]
11✔
1018

1019
    def get_elements(self) -> list[str]:
11✔
1020
        """Returns all elements in the material
1021

1022
        .. versionadded:: 0.12
1023

1024
        Returns
1025
        -------
1026
        elements : list of str
1027
            List of element names
1028

1029
        """
1030

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

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

1037
        Parameters
1038
        ----------
1039
        element : str
1040
            Specifies the element to match when searching through the nuclides
1041

1042
            .. versionadded:: 0.13.2
1043

1044
        Returns
1045
        -------
1046
        nuclides : list of str
1047
            List of nuclide names
1048
        """
1049

1050
        matching_nuclides = []
11✔
1051
        if element:
11✔
1052
            for nuclide in self._nuclides:
11✔
1053
                if re.split(r'(\d+)', nuclide.name)[0] == element:
11✔
1054
                    if nuclide.name not in matching_nuclides:
11✔
1055
                        matching_nuclides.append(nuclide.name)
11✔
1056
        else:
1057
            for nuclide in self._nuclides:
11✔
1058
                if nuclide.name not in matching_nuclides:
11✔
1059
                    matching_nuclides.append(nuclide.name)
11✔
1060

1061
        return matching_nuclides
11✔
1062

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

1066
        Returns
1067
        -------
1068
        nuclides : dict
1069
            Dictionary whose keys are nuclide names and values are 3-tuples of
1070
            (nuclide, density percent, density percent type)
1071

1072
        """
1073

1074
        nuclides = {}
11✔
1075

1076
        for nuclide in self._nuclides:
11✔
1077
            nuclides[nuclide.name] = nuclide
11✔
1078

1079
        return nuclides
11✔
1080

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

1085
        .. versionchanged:: 0.13.1
1086
            The values in the dictionary were changed from a tuple containing
1087
            the nuclide name and the density to just the density.
1088

1089
        Parameters
1090
        ----------
1091
        nuclides : str, optional
1092
            Nuclide for which atom density is desired. If not specified, the
1093
            atom density for each nuclide in the material is given.
1094

1095
            .. versionadded:: 0.13.2
1096

1097
        Returns
1098
        -------
1099
        nuclides : dict
1100
            Dictionary whose keys are nuclide names and values are densities in
1101
            [atom/b-cm]
1102

1103
        """
1104

1105
        sum_density = False
11✔
1106
        if self.density_units == 'sum':
11✔
1107
            sum_density = True
11✔
1108
            density = 0.
11✔
1109
        elif self.density_units == 'macro':
11✔
1110
            density = self.density
×
1111
        elif self.density_units == 'g/cc' or self.density_units == 'g/cm3':
11✔
1112
            density = -self.density
11✔
1113
        elif self.density_units == 'kg/m3':
11✔
1114
            density = -0.001 * self.density
×
1115
        elif self.density_units == 'atom/b-cm':
11✔
1116
            density = self.density
11✔
1117
        elif self.density_units == 'atom/cm3' or self.density_units == 'atom/cc':
11✔
1118
            density = 1.e-24 * self.density
11✔
1119

1120
        # For ease of processing split out nuc, nuc_density,
1121
        # and nuc_density_type into separate arrays
1122
        nucs = []
11✔
1123
        nuc_densities = []
11✔
1124
        nuc_density_types = []
11✔
1125

1126
        for nuc in self.nuclides:
11✔
1127
            nucs.append(nuc.name)
11✔
1128
            nuc_densities.append(nuc.percent)
11✔
1129
            nuc_density_types.append(nuc.percent_type)
11✔
1130

1131
        nuc_densities = np.array(nuc_densities)
11✔
1132
        nuc_density_types = np.array(nuc_density_types)
11✔
1133

1134
        if sum_density:
11✔
1135
            density = np.sum(nuc_densities)
11✔
1136

1137
        percent_in_atom = np.all(nuc_density_types == 'ao')
11✔
1138
        density_in_atom = density > 0.
11✔
1139
        sum_percent = 0.
11✔
1140

1141
        # Convert the weight amounts to atomic amounts
1142
        if not percent_in_atom:
11✔
1143
            for n, nuc in enumerate(nucs):
11✔
1144
                nuc_densities[n] *= self.average_molar_mass / \
11✔
1145
                                    openmc.data.atomic_mass(nuc)
1146

1147
        # Now that we have the atomic amounts, lets finish calculating densities
1148
        sum_percent = np.sum(nuc_densities)
11✔
1149
        nuc_densities = nuc_densities / sum_percent
11✔
1150

1151
        # Convert the mass density to an atom density
1152
        if not density_in_atom:
11✔
1153
            density = -density / self.average_molar_mass * 1.e-24 \
11✔
1154
                      * openmc.data.AVOGADRO
1155

1156
        nuc_densities = density * nuc_densities
11✔
1157

1158
        nuclides = {}
11✔
1159
        for n, nuc in enumerate(nucs):
11✔
1160
            if nuclide is None or nuclide == nuc:
11✔
1161
                nuclides[nuc] = nuc_densities[n]
11✔
1162

1163
        return nuclides
11✔
1164

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

1169
        .. versionadded:: 0.15.1
1170

1171
        Parameters
1172
        ----------
1173
        element : str, optional
1174
            Element for which atom density is desired. If not specified, the
1175
            atom density for each element in the material is given.
1176

1177
        Returns
1178
        -------
1179
        elements : dict
1180
            Dictionary whose keys are element names and values are densities in
1181
            [atom/b-cm]
1182

1183
        """
1184
        if element is not None:
11✔
1185
            element = _get_element_symbol(element)
11✔
1186

1187
        nuc_densities = self.get_nuclide_atom_densities()
11✔
1188

1189
        # Initialize an empty dictionary for summed values
1190
        densities = {}
11✔
1191

1192
        # Accumulate densities for each nuclide
1193
        for nuclide, density in nuc_densities.items():
11✔
1194
            nuc_element = openmc.data.ATOMIC_SYMBOL[openmc.data.zam(nuclide)[0]]
11✔
1195
            if element is None or element == nuc_element:
11✔
1196
                if nuc_element not in densities:
11✔
1197
                    densities[nuc_element] = 0.0
11✔
1198
                densities[nuc_element] += float(density)
11✔
1199

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

1204
        return densities
11✔
1205

1206

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

1211
        .. versionadded:: 0.13.1
1212

1213
        Parameters
1214
        ----------
1215
        units : {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3', 'Ci', 'Ci/m3'}
1216
            Specifies the type of activity to return, options include total
1217
            activity [Bq,Ci], specific [Bq/g, Bq/kg] or volumetric activity
1218
            [Bq/cm3,Ci/m3]. Default is volumetric activity [Bq/cm3].
1219
        by_nuclide : bool
1220
            Specifies if the activity should be returned for the material as a
1221
            whole or per nuclide. Default is False.
1222
        volume : float, optional
1223
            Volume of the material. If not passed, defaults to using the
1224
            :attr:`Material.volume` attribute.
1225

1226
            .. versionadded:: 0.13.3
1227

1228
        Returns
1229
        -------
1230
        Union[dict, float]
1231
            If by_nuclide is True then a dictionary whose keys are nuclide
1232
            names and values are activity is returned. Otherwise the activity
1233
            of the material is returned as a float.
1234
        """
1235

1236
        cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3', 'Ci', 'Ci/m3'})
11✔
1237
        cv.check_type('by_nuclide', by_nuclide, bool)
11✔
1238

1239
        if volume is None:
11✔
1240
            volume = self.volume
11✔
1241

1242
        if units == 'Bq':
11✔
1243
            multiplier = volume
11✔
1244
        elif units == 'Bq/cm3':
11✔
1245
            multiplier = 1
11✔
1246
        elif units == 'Bq/g':
11✔
1247
            multiplier = 1.0 / self.get_mass_density()
11✔
1248
        elif units == 'Bq/kg':
11✔
1249
            multiplier = 1000.0 / self.get_mass_density()
11✔
1250
        elif units == 'Ci':
11✔
1251
            multiplier = volume / _BECQUEREL_PER_CURIE
11✔
1252
        elif units == 'Ci/m3':
11✔
1253
            multiplier = 1e6 / _BECQUEREL_PER_CURIE
11✔
1254

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

1260
        return activity if by_nuclide else sum(activity.values())
11✔
1261

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

1267
        .. versionadded:: 0.13.3
1268

1269
        Parameters
1270
        ----------
1271
        units : {'W', 'W/g', 'W/kg', 'W/cm3'}
1272
            Specifies the units of decay heat to return. Options include total
1273
            heat [W], specific [W/g, W/kg] or volumetric heat [W/cm3].
1274
            Default is total heat [W].
1275
        by_nuclide : bool
1276
            Specifies if the decay heat should be returned for the material as a
1277
            whole or per nuclide. Default is False.
1278
        volume : float, optional
1279
            Volume of the material. If not passed, defaults to using the
1280
            :attr:`Material.volume` attribute.
1281

1282
            .. versionadded:: 0.13.3
1283

1284
        Returns
1285
        -------
1286
        Union[dict, float]
1287
            If `by_nuclide` is True then a dictionary whose keys are nuclide
1288
            names and values are decay heat is returned. Otherwise the decay heat
1289
            of the material is returned as a float.
1290
        """
1291

1292
        cv.check_value('units', units, {'W', 'W/g', 'W/kg', 'W/cm3'})
11✔
1293
        cv.check_type('by_nuclide', by_nuclide, bool)
11✔
1294

1295
        if units == 'W':
11✔
1296
            multiplier = volume if volume is not None else self.volume
11✔
1297
        elif units == 'W/cm3':
11✔
1298
            multiplier = 1
11✔
1299
        elif units == 'W/g':
11✔
1300
            multiplier = 1.0 / self.get_mass_density()
11✔
1301
        elif units == 'W/kg':
11✔
1302
            multiplier = 1000.0 / self.get_mass_density()
11✔
1303

1304
        decayheat = {}
11✔
1305
        for nuclide, atoms_per_bcm in self.get_nuclide_atom_densities().items():
11✔
1306
            decay_erg = openmc.data.decay_energy(nuclide)
11✔
1307
            inv_seconds = openmc.data.decay_constant(nuclide)
11✔
1308
            decay_erg *= openmc.data.JOULE_PER_EV
11✔
1309
            decayheat[nuclide] = inv_seconds * decay_erg * 1e24 * atoms_per_bcm * multiplier
11✔
1310

1311
        return decayheat if by_nuclide else sum(decayheat.values())
11✔
1312

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

1316
        .. versionadded:: 0.13.1
1317

1318
        Parameters
1319
        ----------
1320
        volume : float, optional
1321
            Volume of the material. If not passed, defaults to using the
1322
            :attr:`Material.volume` attribute.
1323

1324
            .. versionadded:: 0.13.3
1325

1326
        Returns
1327
        -------
1328
        dict
1329
            Dictionary whose keys are nuclide names and values are number of
1330
            atoms present in the material.
1331

1332
        """
1333
        if volume is None:
11✔
1334
            volume = self.volume
11✔
1335
        if volume is None:
11✔
1336
            raise ValueError("Volume must be set in order to determine atoms.")
×
1337
        atoms = {}
11✔
1338
        for nuclide, atom_per_bcm in self.get_nuclide_atom_densities().items():
11✔
1339
            atoms[nuclide] = 1.0e24 * atom_per_bcm * volume
11✔
1340
        return atoms
11✔
1341

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

1345
        Parameters
1346
        ----------
1347
        nuclides : str, optional
1348
            Nuclide for which density is desired. If not specified, the density
1349
            for the entire material is given.
1350

1351
        Returns
1352
        -------
1353
        float
1354
            Density of the nuclide/material in [g/cm^3]
1355

1356
        """
1357
        mass_density = 0.0
11✔
1358
        for nuc, atoms_per_bcm in self.get_nuclide_atom_densities(nuclide=nuclide).items():
11✔
1359
            density_i = 1e24 * atoms_per_bcm * openmc.data.atomic_mass(nuc) \
11✔
1360
                        / openmc.data.AVOGADRO
1361
            mass_density += density_i
11✔
1362
        return mass_density
11✔
1363

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

1367
        Note that this method requires that the :attr:`Material.volume` has
1368
        already been set.
1369

1370
        Parameters
1371
        ----------
1372
        nuclides : str, optional
1373
            Nuclide for which mass is desired. If not specified, the density
1374
            for the entire material is given.
1375
        volume : float, optional
1376
            Volume of the material. If not passed, defaults to using the
1377
            :attr:`Material.volume` attribute.
1378

1379
            .. versionadded:: 0.13.3
1380

1381

1382
        Returns
1383
        -------
1384
        float
1385
            Mass of the nuclide/material in [g]
1386

1387
        """
1388
        if volume is None:
11✔
1389
            volume = self.volume
11✔
1390
        if volume is None:
11✔
1391
            raise ValueError("Volume must be set in order to determine mass.")
×
1392
        return volume*self.get_mass_density(nuclide)
11✔
1393

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

1397
        This method determines a waste classification for the material based on
1398
        the NRC regulations (10 CFR 61.55). Note that the NRC regulations do not
1399
        consider many long-lived radionuclides relevant to fusion systems; for
1400
        fusion applications, it is recommended to calculate a waste disposal
1401
        rating based on limits by Fetter et al. using the
1402
        :meth:`~openmc.Material.waste_disposal_rating` method.
1403

1404
        Parameters
1405
        ----------
1406
        metal : bool, optional
1407
            Whether or not the material is in metal form.
1408

1409
        Returns
1410
        -------
1411
        str
1412
            The waste disposal classification, which can be "Class A", "Class
1413
            B", "Class C", or "GTCC" (greater than class C).
1414

1415
        """
1416
        return waste._waste_classification(self, metal=metal)
11✔
1417

1418
    def waste_disposal_rating(
11✔
1419
        self,
1420
        limits: str | dict[str, float] = 'Fetter',
1421
        metal: bool = False,
1422
        by_nuclide: bool = False,
1423
    ) -> float | dict[str, float]:
1424
        """Return the waste disposal rating for the material.
1425

1426
        This method returns a waste disposal rating for the material based on a
1427
        set of specific activity limits. The waste disposal rating is a single
1428
        number that represents the sum of the ratios of the specific activity
1429
        for each radionuclide in the material against a nuclide-specific limit.
1430
        A value less than 1.0 indicates that the material "meets" the limits
1431
        whereas a value greater than 1.0 exceeds the limits.
1432

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

1439
        Parameters
1440
        ----------
1441
        limits : str or dict, optional
1442
            The name of a predefined set of specific activity limits or a
1443
            dictionary that contains specific activity limits for radionuclides,
1444
            where keys are nuclide names and values are activities in units of
1445
            [Ci/m3]. The predefined options are:
1446

1447
            - 'Fetter': Uses limits from Fetter et al. (1990)
1448
            - 'NRC_long': Uses the 10 CFR 61.55 limits for long-lived
1449
              radionuclides
1450
            - 'NRC_short_A': Uses the 10 CFR 61.55 class A limits for
1451
              short-lived radionuclides
1452
            - 'NRC_short_B': Uses the 10 CFR 61.55 class B limits for
1453
              short-lived radionuclides
1454
            - 'NRC_short_C': Uses the 10 CFR 61.55 class C limits for
1455
              short-lived radionuclides
1456
        metal : bool, optional
1457
            Whether or not the material is in metal form (only applicable for
1458
            NRC based limits)
1459
        by_nuclide : bool, optional
1460
            Whether to return the waste disposal rating for each nuclide in the
1461
            material. If True, a dictionary is returned where the keys are the
1462
            nuclide names and the values are the waste disposal ratings for each
1463
            nuclide. If False, a single float value is returned that represents
1464
            the overall waste disposal rating for the material.
1465

1466
        Returns
1467
        -------
1468
        float or dict
1469
            The waste disposal rating for the material or its constituent
1470
            nuclides.
1471

1472
        See also
1473
        --------
1474
        Material.waste_classification()
1475

1476
        """
1477
        return waste._waste_disposal_rating(self, limits, metal, by_nuclide)
11✔
1478

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

1482
        Parameters
1483
        ----------
1484
        memo : dict or None
1485
            A nested dictionary of previously cloned objects. This parameter
1486
            is used internally and should not be specified by the user.
1487

1488
        Returns
1489
        -------
1490
        clone : openmc.Material
1491
            The clone of this material
1492

1493
        """
1494

1495
        if memo is None:
11✔
1496
            memo = {}
11✔
1497

1498
        # If no nemoize'd clone exists, instantiate one
1499
        if self not in memo:
11✔
1500
            # Temporarily remove paths -- this is done so that when the clone is
1501
            # made, it doesn't create a copy of the paths (which are specific to
1502
            # an instance)
1503
            paths = self._paths
11✔
1504
            self._paths = None
11✔
1505

1506
            clone = deepcopy(self)
11✔
1507
            clone.id = None
11✔
1508
            clone._num_instances = None
11✔
1509

1510
            # Restore paths on original instance
1511
            self._paths = paths
11✔
1512

1513
            # Memoize the clone
1514
            memo[self] = clone
11✔
1515

1516
        return memo[self]
11✔
1517

1518
    def _get_nuclide_xml(self, nuclide: NuclideTuple) -> ET.Element:
11✔
1519
        xml_element = ET.Element("nuclide")
11✔
1520
        xml_element.set("name", nuclide.name)
11✔
1521

1522
        # Prevent subnormal numbers from being written to XML, which causes an
1523
        # exception on the C++ side when calling std::stod
1524
        val = nuclide.percent
11✔
1525
        if abs(val) < _SMALLEST_NORMAL:
11✔
1526
            val = 0.0
11✔
1527

1528
        if nuclide.percent_type == 'ao':
11✔
1529
            xml_element.set("ao", str(val))
11✔
1530
        else:
1531
            xml_element.set("wo", str(val))
11✔
1532

1533
        return xml_element
11✔
1534

1535
    def _get_macroscopic_xml(self, macroscopic: str) -> ET.Element:
11✔
1536
        xml_element = ET.Element("macroscopic")
11✔
1537
        xml_element.set("name", macroscopic)
11✔
1538

1539
        return xml_element
11✔
1540

1541
    def _get_nuclides_xml(
11✔
1542
            self, nuclides: Iterable[NuclideTuple],
1543
            nuclides_to_ignore: Iterable[str] | None = None)-> list[ET.Element]:
1544
        xml_elements = []
11✔
1545

1546
        # Remove any nuclides to ignore from the XML export
1547
        if nuclides_to_ignore:
11✔
1548
            nuclides = [nuclide for nuclide in nuclides if nuclide.name not in nuclides_to_ignore]
11✔
1549

1550
        xml_elements = [self._get_nuclide_xml(nuclide) for nuclide in nuclides]
11✔
1551

1552
        return xml_elements
11✔
1553

1554
    def to_xml_element(
11✔
1555
            self, nuclides_to_ignore: Iterable[str] | None = None) -> ET.Element:
1556
        """Return XML representation of the material
1557

1558
        Parameters
1559
        ----------
1560
        nuclides_to_ignore : list of str
1561
            Nuclides to ignore when exporting to XML.
1562

1563
        Returns
1564
        -------
1565
        element : lxml.etree._Element
1566
            XML element containing material data
1567

1568
        """
1569

1570
        # Create Material XML element
1571
        element = ET.Element("material")
11✔
1572
        element.set("id", str(self._id))
11✔
1573

1574
        if len(self._name) > 0:
11✔
1575
            element.set("name", str(self._name))
11✔
1576

1577
        if self._depletable:
11✔
1578
            element.set("depletable", "true")
11✔
1579

1580
        if self._volume:
11✔
1581
            element.set("volume", str(self._volume))
11✔
1582

1583
        if self._ncrystal_cfg:
11✔
1584
            if self._sab:
11✔
1585
                raise ValueError("NCrystal materials are not compatible with S(a,b).")
×
1586
            if self._macroscopic is not None:
11✔
1587
                raise ValueError("NCrystal materials are not compatible with macroscopic cross sections.")
×
1588

1589
            element.set("cfg", str(self._ncrystal_cfg))
11✔
1590

1591
        # Create temperature XML subelement
1592
        if self.temperature is not None:
11✔
1593
            element.set("temperature", str(self.temperature))
11✔
1594

1595
        # Create density XML subelement
1596
        if self._density is not None or self._density_units == 'sum':
11✔
1597
            subelement = ET.SubElement(element, "density")
11✔
1598
            if self._density_units != 'sum':
11✔
1599
                subelement.set("value", str(self._density))
11✔
1600
            subelement.set("units", self._density_units)
11✔
1601
        else:
1602
            raise ValueError(f'Density has not been set for material {self.id}!')
×
1603

1604
        if self._macroscopic is None:
11✔
1605
            # Create nuclide XML subelements
1606
            subelements = self._get_nuclides_xml(self._nuclides,
11✔
1607
                                                 nuclides_to_ignore=nuclides_to_ignore)
1608
            for subelement in subelements:
11✔
1609
                element.append(subelement)
11✔
1610
        else:
1611
            # Create macroscopic XML subelements
1612
            subelement = self._get_macroscopic_xml(self._macroscopic)
11✔
1613
            element.append(subelement)
11✔
1614

1615
        if self._sab:
11✔
1616
            for sab in self._sab:
11✔
1617
                subelement = ET.SubElement(element, "sab")
11✔
1618
                subelement.set("name", sab[0])
11✔
1619
                if sab[1] != 1.0:
11✔
1620
                    subelement.set("fraction", str(sab[1]))
11✔
1621

1622
        if self._isotropic:
11✔
1623
            subelement = ET.SubElement(element, "isotropic")
11✔
1624
            subelement.text = ' '.join(self._isotropic)
11✔
1625

1626
        return element
11✔
1627

1628
    @classmethod
11✔
1629
    def mix_materials(cls, materials, fracs: Iterable[float],
11✔
1630
                      percent_type: str = 'ao', **kwargs) -> Material:
1631
        """Mix materials together based on atom, weight, or volume fractions
1632

1633
        .. versionadded:: 0.12
1634

1635
        Parameters
1636
        ----------
1637
        materials : Iterable of openmc.Material
1638
            Materials to combine
1639
        fracs : Iterable of float
1640
            Fractions of each material to be combined
1641
        percent_type : {'ao', 'wo', 'vo'}
1642
            Type of percentage, must be one of 'ao', 'wo', or 'vo', to signify atom
1643
            percent (molar percent), weight percent, or volume percent,
1644
            optional. Defaults to 'ao'
1645
        **kwargs
1646
            Keyword arguments passed to :class:`openmc.Material`
1647

1648
        Returns
1649
        -------
1650
        openmc.Material
1651
            Mixture of the materials
1652

1653
        """
1654

1655
        cv.check_type('materials', materials, Iterable, Material)
11✔
1656
        cv.check_type('fracs', fracs, Iterable, Real)
11✔
1657
        cv.check_value('percent type', percent_type, {'ao', 'wo', 'vo'})
11✔
1658

1659
        fracs = np.asarray(fracs)
11✔
1660
        void_frac = 1. - np.sum(fracs)
11✔
1661

1662
        # Warn that fractions don't add to 1, set remainder to void, or raise
1663
        # an error if percent_type isn't 'vo'
1664
        if not np.isclose(void_frac, 0.):
11✔
1665
            if percent_type in ('ao', 'wo'):
11✔
1666
                msg = ('A non-zero void fraction is not acceptable for '
×
1667
                       'percent_type: {}'.format(percent_type))
1668
                raise ValueError(msg)
×
1669
            else:
1670
                msg = ('Warning: sum of fractions do not add to 1, void '
11✔
1671
                       'fraction set to {}'.format(void_frac))
1672
                warnings.warn(msg)
11✔
1673

1674
        # Calculate appropriate weights which are how many cc's of each
1675
        # material are found in 1cc of the composite material
1676
        amms = np.asarray([mat.average_molar_mass for mat in materials])
11✔
1677
        mass_dens = np.asarray([mat.get_mass_density() for mat in materials])
11✔
1678
        if percent_type == 'ao':
11✔
1679
            wgts = fracs * amms / mass_dens
11✔
1680
            wgts /= np.sum(wgts)
11✔
1681
        elif percent_type == 'wo':
11✔
1682
            wgts = fracs / mass_dens
11✔
1683
            wgts /= np.sum(wgts)
11✔
1684
        elif percent_type == 'vo':
11✔
1685
            wgts = fracs
11✔
1686

1687
        # If any of the involved materials contain S(a,b) tables raise an error
1688
        sab_names = set(sab[0] for mat in materials for sab in mat._sab)
11✔
1689
        if sab_names:
11✔
1690
            msg = ('Currently we do not support mixing materials containing '
×
1691
                   'S(a,b) tables')
1692
            raise NotImplementedError(msg)
×
1693

1694
        # Add nuclide densities weighted by appropriate fractions
1695
        nuclides_per_cc = defaultdict(float)
11✔
1696
        mass_per_cc = defaultdict(float)
11✔
1697
        for mat, wgt in zip(materials, wgts):
11✔
1698
            for nuc, atoms_per_bcm in mat.get_nuclide_atom_densities().items():
11✔
1699
                nuc_per_cc = wgt*1.e24*atoms_per_bcm
11✔
1700
                nuclides_per_cc[nuc] += nuc_per_cc
11✔
1701
                mass_per_cc[nuc] += nuc_per_cc*openmc.data.atomic_mass(nuc) / \
11✔
1702
                                    openmc.data.AVOGADRO
1703

1704
        # Create the new material with the desired name
1705
        if "name" not in kwargs:
11✔
1706
            kwargs["name"] = '-'.join([f'{m.name}({f})' for m, f in
11✔
1707
                             zip(materials, fracs)])
1708

1709
        new_mat = cls(**kwargs)
11✔
1710

1711
        # Compute atom fractions of nuclides and add them to the new material
1712
        tot_nuclides_per_cc = np.sum([dens for dens in nuclides_per_cc.values()])
11✔
1713
        for nuc, atom_dens in nuclides_per_cc.items():
11✔
1714
            new_mat.add_nuclide(nuc, atom_dens/tot_nuclides_per_cc, 'ao')
11✔
1715

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

1720
        # If any of the involved materials is depletable, the new material is
1721
        # depletable
1722
        new_mat.depletable = any(mat.depletable for mat in materials)
11✔
1723

1724
        return new_mat
11✔
1725

1726
    @classmethod
11✔
1727
    def from_xml_element(cls, elem: ET.Element) -> Material:
11✔
1728
        """Generate material from an XML element
1729

1730
        Parameters
1731
        ----------
1732
        elem : lxml.etree._Element
1733
            XML element
1734

1735
        Returns
1736
        -------
1737
        openmc.Material
1738
            Material generated from XML element
1739

1740
        """
1741
        mat_id = int(get_text(elem, 'id'))
11✔
1742

1743
        # Add NCrystal material from cfg string
1744
        cfg = get_text(elem, "cfg")
11✔
1745
        if cfg is not None:
11✔
1746
            return Material.from_ncrystal(cfg, material_id=mat_id)
11✔
1747

1748
        mat = cls(mat_id)
11✔
1749
        mat.name = get_text(elem, 'name')
11✔
1750

1751
        temperature = get_text(elem, "temperature")
11✔
1752
        if temperature is not None:
11✔
1753
            mat.temperature = float(temperature)
11✔
1754

1755
        volume = get_text(elem, "volume")
11✔
1756
        if volume is not None:
11✔
1757
            mat.volume = float(volume)
11✔
1758

1759
        # Get each nuclide
1760
        for nuclide in elem.findall('nuclide'):
11✔
1761
            name = get_text(nuclide, "name")
11✔
1762
            if 'ao' in nuclide.attrib:
11✔
1763
                mat.add_nuclide(name, float(nuclide.attrib['ao']))
11✔
1764
            elif 'wo' in nuclide.attrib:
11✔
1765
                mat.add_nuclide(name, float(nuclide.attrib['wo']), 'wo')
11✔
1766

1767
        # Get depletable attribute
1768
        depletable = get_text(elem, "depletable")
11✔
1769
        mat.depletable = depletable in ('true', '1')
11✔
1770

1771
        # Get each S(a,b) table
1772
        for sab in elem.findall('sab'):
11✔
1773
            fraction = float(get_text(sab, "fraction", 1.0))
11✔
1774
            name = get_text(sab, "name")
11✔
1775
            mat.add_s_alpha_beta(name, fraction)
11✔
1776

1777
        # Get total material density
1778
        density = elem.find('density')
11✔
1779
        units = get_text(density, "units")
11✔
1780
        if units == 'sum':
11✔
1781
            mat.set_density(units)
11✔
1782
        else:
1783
            value = float(get_text(density, 'value'))
11✔
1784
            mat.set_density(units, value)
11✔
1785

1786
        # Check for isotropic scattering nuclides
1787
        isotropic = get_elem_list(elem, "isotropic", str)
11✔
1788
        if isotropic is not None:
11✔
1789
            mat.isotropic = isotropic
11✔
1790

1791
        return mat
11✔
1792

1793
    def deplete(
11✔
1794
        self,
1795
        multigroup_flux: Sequence[float],
1796
        energy_group_structure: Sequence[float] | str,
1797
        timesteps: Sequence[float] | Sequence[tuple[float, str]],
1798
        source_rates: float | Sequence[float],
1799
        timestep_units: str = 's',
1800
        chain_file: cv.PathLike | "openmc.deplete.Chain" | None = None,
1801
        reactions: Sequence[str] | None = None,
1802
    ) -> list[openmc.Material]:
1803
        """Depletes that material, evolving the nuclide densities
1804

1805
        .. versionadded:: 0.15.3
1806

1807
        Parameters
1808
        ----------
1809
        multigroup_flux: Sequence[float]
1810
            Energy-dependent multigroup flux values, where each sublist corresponds
1811
            to a specific material. Will be normalized so that it sums to 1.
1812
        energy_group_structure : Sequence[float] | str
1813
            Energy group boundaries in [eV] or the name of the group structure.
1814
        timesteps : iterable of float or iterable of tuple
1815
            Array of timesteps. Note that values are not cumulative. The units are
1816
            specified by the `timestep_units` argument when `timesteps` is an
1817
            iterable of float. Alternatively, units can be specified for each step
1818
            by passing an iterable of (value, unit) tuples.
1819
        source_rates : float or iterable of float, optional
1820
            Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for
1821
            each interval in :attr:`timesteps`
1822
        timestep_units : {'s', 'min', 'h', 'd', 'a', 'MWd/kg'}
1823
            Units for values specified in the `timesteps` argument. 's' means
1824
            seconds, 'min' means minutes, 'h' means hours, 'a' means Julian years
1825
            and 'MWd/kg' indicates that the values are given in burnup (MW-d of
1826
            energy deposited per kilogram of initial heavy metal).
1827
        chain_file : PathLike or Chain
1828
            Path to the depletion chain XML file or instance of openmc.deplete.Chain.
1829
            Defaults to ``openmc.config['chain_file']``.
1830
        reactions : list of str, optional
1831
            Reactions to get cross sections for. If not specified, all neutron
1832
            reactions listed in the depletion chain file are used.
1833

1834
        Returns
1835
        -------
1836
        list of openmc.Material, one for each timestep
1837

1838
        """
1839

1840
        materials = openmc.Materials([self])
11✔
1841

1842
        depleted_materials_dict = materials.deplete(
11✔
1843
            multigroup_fluxes=[multigroup_flux],
1844
            energy_group_structures=[energy_group_structure],
1845
            timesteps=timesteps,
1846
            source_rates=source_rates,
1847
            timestep_units=timestep_units,
1848
            chain_file=chain_file,
1849
            reactions=reactions,
1850
        )
1851

1852
        return depleted_materials_dict[self.id]
11✔
1853

1854

1855
    def mean_free_path(self, energy: float) -> float:
11✔
1856
        """Calculate the mean free path of neutrons in the material at a given
1857
        energy.
1858

1859
        .. versionadded:: 0.15.3
1860

1861
        Parameters
1862
        ----------
1863
        energy : float
1864
            Neutron energy in eV
1865

1866
        Returns
1867
        -------
1868
        float
1869
            Mean free path in cm
1870

1871
        """
1872
        from openmc.plotter import _calculate_cexs_elem_mat
11✔
1873

1874
        energy_grid, cexs = _calculate_cexs_elem_mat(
11✔
1875
            this=self,
1876
            types=["total"],
1877
        )
1878
        total_cexs = cexs[0]
11✔
1879

1880
        interpolated_cexs = float(np.interp(energy, energy_grid, total_cexs))
11✔
1881

1882
        return 1.0 / interpolated_cexs
11✔
1883

1884

1885
class Materials(cv.CheckedList):
11✔
1886
    """Collection of Materials used for an OpenMC simulation.
1887

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

1892
    >>> fuel = openmc.Material()
1893
    >>> clad = openmc.Material()
1894
    >>> water = openmc.Material()
1895
    >>> m = openmc.Materials([fuel])
1896
    >>> m.append(water)
1897
    >>> m += [clad]
1898

1899
    Parameters
1900
    ----------
1901
    materials : Iterable of openmc.Material
1902
        Materials to add to the collection
1903

1904
    Attributes
1905
    ----------
1906
    cross_sections : str or path-like
1907
        Indicates the path to an XML cross section listing file (usually named
1908
        cross_sections.xml). If it is not set, the
1909
        :envvar:`OPENMC_CROSS_SECTIONS` environment variable will be used for
1910
        continuous-energy calculations and :envvar:`OPENMC_MG_CROSS_SECTIONS`
1911
        will be used for multi-group calculations to find the path to the HDF5
1912
        cross section file.
1913

1914
    """
1915

1916
    def __init__(self, materials=None):
11✔
1917
        super().__init__(Material, 'materials collection')
11✔
1918
        self._cross_sections = None
11✔
1919

1920
        if materials is not None:
11✔
1921
            self += materials
11✔
1922

1923
    @property
11✔
1924
    def cross_sections(self) -> Path | None:
11✔
1925
        return self._cross_sections
11✔
1926

1927
    @cross_sections.setter
11✔
1928
    def cross_sections(self, cross_sections):
11✔
1929
        if cross_sections is not None:
11✔
1930
            self._cross_sections = input_path(cross_sections)
11✔
1931

1932
    def append(self, material):
11✔
1933
        """Append material to collection
1934

1935
        Parameters
1936
        ----------
1937
        material : openmc.Material
1938
            Material to append
1939

1940
        """
1941
        super().append(material)
11✔
1942

1943
    def insert(self, index: int, material):
11✔
1944
        """Insert material before index
1945

1946
        Parameters
1947
        ----------
1948
        index : int
1949
            Index in list
1950
        material : openmc.Material
1951
            Material to insert
1952

1953
        """
1954
        super().insert(index, material)
×
1955

1956
    def make_isotropic_in_lab(self):
11✔
1957
        for material in self:
11✔
1958
            material.make_isotropic_in_lab()
11✔
1959

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

1964
        Parameters
1965
        ----------
1966
        file : IOTextWrapper
1967
            Open file handle to write content into.
1968
        header : bool
1969
            Whether or not to write the XML header
1970
        level : int
1971
            Indentation level of materials element
1972
        spaces_per_level : int
1973
            Number of spaces per indentation
1974
        trailing_indentation : bool
1975
            Whether or not to write a trailing indentation for the materials element
1976
        nuclides_to_ignore : list of str
1977
            Nuclides to ignore when exporting to XML.
1978

1979
        """
1980
        indentation = level*spaces_per_level*' '
11✔
1981
        # Write the header and the opening tag for the root element.
1982
        if header:
11✔
1983
            file.write("<?xml version='1.0' encoding='utf-8'?>\n")
11✔
1984
        file.write(indentation+'<materials>\n')
11✔
1985

1986
        # Write the <cross_sections> element.
1987
        if self.cross_sections is not None:
11✔
1988
            element = ET.Element('cross_sections')
11✔
1989
            element.text = str(self.cross_sections)
11✔
1990
            clean_indentation(element, level=level+1)
11✔
1991
            element.tail = element.tail.strip(' ')
11✔
1992
            file.write((level+1)*spaces_per_level*' ')
11✔
1993
            file.write(ET.tostring(element, encoding="unicode"))
11✔
1994

1995
        # Write the <material> elements.
1996
        for material in sorted(set(self), key=lambda x: x.id):
11✔
1997
            element = material.to_xml_element(nuclides_to_ignore=nuclides_to_ignore)
11✔
1998
            clean_indentation(element, level=level+1)
11✔
1999
            element.tail = element.tail.strip(' ')
11✔
2000
            file.write((level+1)*spaces_per_level*' ')
11✔
2001
            file.write(ET.tostring(element, encoding="unicode"))
11✔
2002

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

2006
        # Write a trailing indentation for the next element
2007
        # at this level if needed
2008
        if trailing_indent:
11✔
2009
            file.write(indentation)
11✔
2010

2011
    def export_to_xml(self, path: PathLike = 'materials.xml',
11✔
2012
                      nuclides_to_ignore: Iterable[str] | None = None):
2013
        """Export material collection to an XML file.
2014

2015
        Parameters
2016
        ----------
2017
        path : str
2018
            Path to file to write. Defaults to 'materials.xml'.
2019
        nuclides_to_ignore : list of str
2020
            Nuclides to ignore when exporting to XML.
2021

2022
        """
2023
        # Check if path is a directory
2024
        p = Path(path)
11✔
2025
        if p.is_dir():
11✔
2026
            p /= 'materials.xml'
11✔
2027

2028
        # Write materials to the file one-at-a-time.  This significantly reduces
2029
        # memory demand over allocating a complete ElementTree and writing it in
2030
        # one go.
2031
        with open(str(p), 'w', encoding='utf-8',
11✔
2032
                  errors='xmlcharrefreplace') as fh:
2033
            self._write_xml(fh, nuclides_to_ignore=nuclides_to_ignore)
11✔
2034

2035
    @classmethod
11✔
2036
    def from_xml_element(cls, elem) -> Materials:
11✔
2037
        """Generate materials collection from XML file
2038

2039
        Parameters
2040
        ----------
2041
        elem : lxml.etree._Element
2042
            XML element
2043

2044
        Returns
2045
        -------
2046
        openmc.Materials
2047
            Materials collection
2048

2049
        """
2050
        # Generate each material
2051
        materials = cls()
11✔
2052
        for material in elem.findall('material'):
11✔
2053
            materials.append(Material.from_xml_element(material))
11✔
2054

2055
        # Check for cross sections settings
2056
        xs = get_text(elem, "cross_sections")
11✔
2057
        if xs is not None:
11✔
2058
            materials.cross_sections = xs
11✔
2059

2060
        return materials
11✔
2061

2062
    @classmethod
11✔
2063
    def from_xml(cls, path: PathLike = 'materials.xml') -> Materials:
11✔
2064
        """Generate materials collection from XML file
2065

2066
        Parameters
2067
        ----------
2068
        path : str
2069
            Path to materials XML file
2070

2071
        Returns
2072
        -------
2073
        openmc.Materials
2074
            Materials collection
2075

2076
        """
2077
        parser = ET.XMLParser(huge_tree=True)
11✔
2078
        tree = ET.parse(path, parser=parser)
11✔
2079
        root = tree.getroot()
11✔
2080

2081
        return cls.from_xml_element(root)
11✔
2082

2083

2084
    def deplete(
11✔
2085
        self,
2086
        multigroup_fluxes: Sequence[Sequence[float]],
2087
        energy_group_structures: Sequence[Sequence[float] | str],
2088
        timesteps: Sequence[float] | Sequence[tuple[float, str]],
2089
        source_rates: float | Sequence[float],
2090
        timestep_units: str = 's',
2091
        chain_file: cv.PathLike | "openmc.deplete.Chain" | None = None,
2092
        reactions: Sequence[str] | None = None,
2093
    ) -> Dict[int, list[openmc.Material]]:
2094
        """Depletes that material, evolving the nuclide densities
2095

2096
        .. versionadded:: 0.15.3
2097

2098
        Parameters
2099
        ----------
2100
        multigroup_fluxes: Sequence[Sequence[float]]
2101
            Energy-dependent multigroup flux values, where each sublist corresponds
2102
            to a specific material. Will be normalized so that it sums to 1.
2103
        energy_group_structures': Sequence[Sequence[float] | str]
2104
            Energy group boundaries in [eV] or the name of the group structure.
2105
        timesteps : iterable of float or iterable of tuple
2106
            Array of timesteps. Note that values are not cumulative. The units are
2107
            specified by the `timestep_units` argument when `timesteps` is an
2108
            iterable of float. Alternatively, units can be specified for each step
2109
            by passing an iterable of (value, unit) tuples.
2110
        source_rates : float or iterable of float, optional
2111
            Source rate in [neutron/sec] or neutron flux in [neutron/s-cm^2] for
2112
            each interval in :attr:`timesteps`
2113
        timestep_units : {'s', 'min', 'h', 'd', 'a', 'MWd/kg'}
2114
            Units for values specified in the `timesteps` argument. 's' means
2115
            seconds, 'min' means minutes, 'h' means hours, 'a' means Julian years
2116
            and 'MWd/kg' indicates that the values are given in burnup (MW-d of
2117
            energy deposited per kilogram of initial heavy metal).
2118
        chain_file : PathLike or Chain
2119
            Path to the depletion chain XML file or instance of openmc.deplete.Chain.
2120
            Defaults to ``openmc.config['chain_file']``.
2121
        reactions : list of str, optional
2122
            Reactions to get cross sections for. If not specified, all neutron
2123
            reactions listed in the depletion chain file are used.
2124

2125
        Returns
2126
        -------
2127
        list of openmc.Material, one for each timestep
2128

2129
        """
2130

2131
        import openmc.deplete
11✔
2132
        from .deplete.chain import _get_chain
11✔
2133

2134
        # setting all materials to be depletable
2135
        for mat in self:
11✔
2136
            mat.depletable = True
11✔
2137

2138
        chain = _get_chain(chain_file)
11✔
2139

2140
        # Create MicroXS objects for all materials
2141
        micros = []
11✔
2142
        fluxes = []
11✔
2143

2144
        with openmc.lib.TemporarySession():
11✔
2145
            for material, flux, energy in zip(
11✔
2146
                self, multigroup_fluxes, energy_group_structures
2147
            ):
2148
                temperature = material.temperature or 293.6
11✔
2149
                micro_xs = openmc.deplete.MicroXS.from_multigroup_flux(
11✔
2150
                    energies=energy,
2151
                    multigroup_flux=flux,
2152
                    chain_file=chain,
2153
                    temperature=temperature,
2154
                    reactions=reactions,
2155
                )
2156
                micros.append(micro_xs)
11✔
2157
                fluxes.append(material.volume)
11✔
2158

2159
        # Create a single operator for all materials
2160
        operator = openmc.deplete.IndependentOperator(
11✔
2161
            materials=self,
2162
            fluxes=fluxes,
2163
            micros=micros,
2164
            normalization_mode="source-rate",
2165
            chain_file=chain,
2166
        )
2167

2168
        integrator = openmc.deplete.PredictorIntegrator(
11✔
2169
            operator=operator,
2170
            timesteps=timesteps,
2171
            source_rates=source_rates,
2172
            timestep_units=timestep_units,
2173
        )
2174

2175
        with tempfile.TemporaryDirectory() as tmpdir:
11✔
2176
            # Run integrator
2177
            results_path = Path(tmpdir) / "depletion_results.h5"
11✔
2178
            integrator.integrate(path=results_path)
11✔
2179

2180
            # Load depletion results
2181
            results = openmc.deplete.Results(results_path)
11✔
2182

2183
            # For each material, get activated composition at each timestep
2184
            all_depleted_materials = {
11✔
2185
                material.id: [
2186
                    result.get_material(str(material.id))
2187
                    for result in results
2188
                ]
2189
                for material in self
2190
            }
2191

2192
        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