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

openmc-dev / openmc / 13546000884

26 Feb 2025 02:19PM UTC coverage: 85.015% (-0.2%) from 85.186%
13546000884

Pull #3328

github

web-flow
Merge 67a173195 into e060534ff
Pull Request #3328: NCrystal becomes runtime rather than buildtime dependency

81 of 108 new or added lines in 6 files covered. (75.0%)

467 existing lines in 21 files now uncovered.

51062 of 60062 relevant lines covered (85.02%)

32488887.43 hits per line

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

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

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

15
import openmc
12✔
16
import openmc.data
12✔
17
import openmc.checkvalue as cv
12✔
18
from ._xml import clean_indentation, reorder_attributes
12✔
19
from .mixin import IDManagerMixin
12✔
20
from .utility_funcs import input_path
12✔
21
from openmc.checkvalue import PathLike
12✔
22
from openmc.stats import Univariate, Discrete, Mixture
12✔
23
from openmc.data.data import _get_element_symbol
12✔
24

25

26
# Units for density supported by OpenMC
27
DENSITY_UNITS = ('g/cm3', 'g/cc', 'kg/m3', 'atom/b-cm', 'atom/cm3', 'sum',
12✔
28
                 'macro')
29

30
# Smallest normalized floating point number
31
_SMALLEST_NORMAL = sys.float_info.min
12✔
32

33

34
NuclideTuple = namedtuple('NuclideTuple', ['name', 'percent', 'percent_type'])
12✔
35

36

37
class Material(IDManagerMixin):
12✔
38
    """A material composed of a collection of nuclides/elements.
39

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

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

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

103
        .. versionadded:: 0.13.3
104

105
    """
106

107
    next_id = 1
12✔
108
    used_ids = set()
12✔
109

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

125
        # A list of tuples (nuclide, percent, percent type)
126
        self._nuclides = []
12✔
127

128
        # The single instance of Macroscopic data present in this material
129
        # (only one is allowed, hence this is different than _nuclides, etc)
130
        self._macroscopic = None
12✔
131

132
        # If specified, a list of table names
133
        self._sab = []
12✔
134

135
    def __repr__(self) -> str:
12✔
136
        string = 'Material\n'
12✔
137
        string += '{: <16}=\t{}\n'.format('\tID', self._id)
12✔
138
        string += '{: <16}=\t{}\n'.format('\tName', self._name)
12✔
139
        string += '{: <16}=\t{}\n'.format('\tTemperature', self._temperature)
12✔
140

141
        string += '{: <16}=\t{}'.format('\tDensity', self._density)
12✔
142
        string += f' [{self._density_units}]\n'
12✔
143

144
        string += '{: <16}=\t{} [cm^3]\n'.format('\tVolume', self._volume)
12✔
145
        string += '{: <16}=\t{}\n'.format('\tDepletable', self._depletable)
12✔
146

147
        string += '{: <16}\n'.format('\tS(a,b) Tables')
12✔
148

149
        if self._ncrystal_cfg:
12✔
150
            string += '{: <16}=\t{}\n'.format('\tNCrystal conf', self._ncrystal_cfg)
×
151

152
        for sab in self._sab:
12✔
153
            string += '{: <16}=\t{}\n'.format('\tS(a,b)', sab)
12✔
154

155
        string += '{: <16}\n'.format('\tNuclides')
12✔
156

157
        for nuclide, percent, percent_type in self._nuclides:
12✔
158
            string += '{: <16}'.format('\t{}'.format(nuclide))
12✔
159
            string += f'=\t{percent: <12} [{percent_type}]\n'
12✔
160

161
        if self._macroscopic is not None:
12✔
162
            string += '{: <16}\n'.format('\tMacroscopic Data')
12✔
163
            string += '{: <16}'.format('\t{}'.format(self._macroscopic))
12✔
164

165
        return string
12✔
166

167
    @property
12✔
168
    def name(self) -> str | None:
12✔
169
        return self._name
12✔
170

171
    @name.setter
12✔
172
    def name(self, name: str | None):
12✔
173
        if name is not None:
12✔
174
            cv.check_type(f'name for Material ID="{self._id}"',
12✔
175
                          name, str)
176
            self._name = name
12✔
177
        else:
178
            self._name = ''
12✔
179

180
    @property
12✔
181
    def temperature(self) -> float | None:
12✔
182
        return self._temperature
12✔
183

184
    @temperature.setter
12✔
185
    def temperature(self, temperature: Real | None):
12✔
186
        cv.check_type(f'Temperature for Material ID="{self._id}"',
12✔
187
                      temperature, (Real, type(None)))
188
        self._temperature = temperature
12✔
189

190
    @property
12✔
191
    def density(self) -> float | None:
12✔
192
        return self._density
12✔
193

194
    @property
12✔
195
    def density_units(self) -> str:
12✔
196
        return self._density_units
12✔
197

198
    @property
12✔
199
    def depletable(self) -> bool:
12✔
200
        return self._depletable
12✔
201

202
    @depletable.setter
12✔
203
    def depletable(self, depletable: bool):
12✔
204
        cv.check_type(f'Depletable flag for Material ID="{self._id}"',
12✔
205
                      depletable, bool)
206
        self._depletable = depletable
12✔
207

208
    @property
12✔
209
    def paths(self) -> list[str]:
12✔
210
        if self._paths is None:
12✔
211
            raise ValueError('Material instance paths have not been determined. '
×
212
                             'Call the Geometry.determine_paths() method.')
213
        return self._paths
12✔
214

215
    @property
12✔
216
    def num_instances(self) -> int:
12✔
217
        if self._num_instances is None:
12✔
218
            raise ValueError(
×
219
                'Number of material instances have not been determined. Call '
220
                'the Geometry.determine_paths() method.')
221
        return self._num_instances
12✔
222

223
    @property
12✔
224
    def nuclides(self) -> list[namedtuple]:
12✔
225
        return self._nuclides
12✔
226

227
    @property
12✔
228
    def isotropic(self) -> list[str]:
12✔
229
        return self._isotropic
12✔
230

231
    @isotropic.setter
12✔
232
    def isotropic(self, isotropic: Iterable[str]):
12✔
233
        cv.check_iterable_type('Isotropic scattering nuclides', isotropic,
12✔
234
                               str)
235
        self._isotropic = list(isotropic)
12✔
236

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

251
        # Compute and return the molar mass
252
        return mass / moles
12✔
253

254
    @property
12✔
255
    def volume(self) -> float | None:
12✔
256
        return self._volume
12✔
257

258
    @volume.setter
12✔
259
    def volume(self, volume: Real):
12✔
260
        if volume is not None:
12✔
261
            cv.check_type('material volume', volume, Real)
12✔
262
        self._volume = volume
12✔
263

264
    @property
12✔
265
    def ncrystal_cfg(self) -> str | None:
12✔
266
        return self._ncrystal_cfg
12✔
267

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

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

288
    def get_decay_photon_energy(
12✔
289
            self,
290
            clip_tolerance: float = 1e-6,
291
            units: str = 'Bq',
292
            volume: float | None = None
293
        ) -> Univariate | None:
294
        r"""Return energy distribution of decay photons from unstable nuclides.
295

296
        .. versionadded:: 0.14.0
297

298
        Parameters
299
        ----------
300
        clip_tolerance : float
301
            Maximum fraction of :math:`\sum_i x_i p_i` for discrete
302
            distributions that will be discarded.
303
        units : {'Bq', 'Bq/g', 'Bq/cm3'}
304
            Specifies the units on the integral of the distribution.
305
        volume : float, optional
306
            Volume of the material. If not passed, defaults to using the
307
            :attr:`Material.volume` attribute.
308

309
        Returns
310
        -------
311
        Univariate or None
312
            Decay photon energy distribution. The integral of this distribution
313
            is the total intensity of the photon source in the requested units.
314

315
        """
316
        cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/cm3'})
12✔
317
        if units == 'Bq':
12✔
318
            multiplier = volume if volume is not None else self.volume
12✔
319
            if multiplier is None:
12✔
320
                raise ValueError("volume must be specified if units='Bq'")
×
321
        elif units == 'Bq/cm3':
12✔
322
            multiplier = 1
12✔
323
        elif units == 'Bq/g':
×
324
            multiplier = 1.0 / self.get_mass_density()
×
325

326
        dists = []
12✔
327
        probs = []
12✔
328
        for nuc, atoms_per_bcm in self.get_nuclide_atom_densities().items():
12✔
329
            source_per_atom = openmc.data.decay_photon_energy(nuc)
12✔
330
            if source_per_atom is not None and atoms_per_bcm > 0.0:
12✔
331
                dists.append(source_per_atom)
12✔
332
                probs.append(1e24 * atoms_per_bcm * multiplier)
12✔
333

334
        # If no photon sources, exit early
335
        if not dists:
12✔
336
            return None
12✔
337

338
        # Get combined distribution, clip low-intensity values in discrete spectra
339
        combined = openmc.data.combine_distributions(dists, probs)
12✔
340
        if isinstance(combined, (Discrete, Mixture)):
12✔
341
            combined.clip(clip_tolerance, inplace=True)
12✔
342

343
        # If clipping resulted in a single distribution within a mixture, pick
344
        # out that single distribution
345
        if isinstance(combined, Mixture) and len(combined.distribution) == 1:
12✔
346
            combined = combined.distribution[0]
×
347

348
        return combined
12✔
349

350
    @classmethod
12✔
351
    def from_hdf5(cls, group: h5py.Group) -> Material:
12✔
352
        """Create material from HDF5 group
353

354
        Parameters
355
        ----------
356
        group : h5py.Group
357
            Group in HDF5 file
358

359
        Returns
360
        -------
361
        openmc.Material
362
            Material instance
363

364
        """
365
        mat_id = int(group.name.split('/')[-1].lstrip('material '))
12✔
366

367
        name = group['name'][()].decode() if 'name' in group else ''
12✔
368
        density = group['atom_density'][()]
12✔
369
        if 'nuclide_densities' in group:
12✔
370
            nuc_densities = group['nuclide_densities'][()]
12✔
371

372
        # Create the Material
373
        material = cls(mat_id, name)
12✔
374
        material.depletable = bool(group.attrs['depletable'])
12✔
375
        if 'volume' in group.attrs:
12✔
376
            material.volume = group.attrs['volume']
12✔
377
        if "temperature" in group.attrs:
12✔
378
            material.temperature = group.attrs["temperature"]
12✔
379

380
        # Read the names of the S(a,b) tables for this Material and add them
381
        if 'sab_names' in group:
12✔
382
            sab_tables = group['sab_names'][()]
12✔
383
            for sab_table in sab_tables:
12✔
384
                name = sab_table.decode()
12✔
385
                material.add_s_alpha_beta(name)
12✔
386

387
        # Set the Material's density to atom/b-cm as used by OpenMC
388
        material.set_density(density=density, units='atom/b-cm')
12✔
389

390
        if 'nuclides' in group:
12✔
391
            nuclides = group['nuclides'][()]
12✔
392
            # Add all nuclides to the Material
393
            for fullname, density in zip(nuclides, nuc_densities):
12✔
394
                name = fullname.decode().strip()
12✔
395
                material.add_nuclide(name, percent=density, percent_type='ao')
12✔
396
        if 'macroscopics' in group:
12✔
397
            macroscopics = group['macroscopics'][()]
12✔
398
            # Add all macroscopics to the Material
399
            for fullname in macroscopics:
12✔
400
                name = fullname.decode().strip()
12✔
401
                material.add_macroscopic(name)
12✔
402

403
        return material
12✔
404

405
    @classmethod
12✔
406
    def from_ncrystal(cls, cfg, **kwargs) -> Material:
12✔
407
        """Create material from NCrystal configuration string.
408

409
        Density, temperature, and material composition, and (ultimately) thermal
410
        neutron scattering will be automatically be provided by NCrystal based
411
        on this string. The name and material_id parameters are simply passed on
412
        to the Material constructor.
413

414
        .. versionadded:: 0.13.3
415

416
        Parameters
417
        ----------
418
        cfg : str
419
            NCrystal configuration string
420
        **kwargs
421
            Keyword arguments passed to :class:`openmc.Material`
422

423
        Returns
424
        -------
425
        openmc.Material
426
            Material instance
427

428
        """
429

430
        try:
1✔
431
            import NCrystal
1✔
NEW
432
        except ModuleNotFoundError as e:
×
NEW
433
            raise RuntimeError('The .from_ncrystal method requires'
×
434
                               ' NCrystal to be installed.') from e
435
        nc_mat = NCrystal.createInfo(cfg)
1✔
436

437
        def openmc_natabund(Z):
1✔
438
            #nc_mat.getFlattenedComposition might need natural abundancies.
439
            #This call-back function is used so NCrystal can flatten composition
440
            #using OpenMC's natural abundancies. In practice this function will
441
            #only get invoked in the unlikely case where a material is specified
442
            #by referring both to natural elements and specific isotopes of the
443
            #same element.
444
            elem_name = openmc.data.ATOMIC_SYMBOL[Z]
×
445
            return [
×
446
                (int(iso_name[len(elem_name):]), abund)
447
                for iso_name, abund in openmc.data.isotopes(elem_name)
448
            ]
449

450
        flat_compos = nc_mat.getFlattenedComposition(
1✔
451
            preferNaturalElements=True, naturalAbundProvider=openmc_natabund)
452

453
        # Create the Material
454
        material = cls(temperature=nc_mat.getTemperature(), **kwargs)
1✔
455

456
        for Z, A_vals in flat_compos:
1✔
457
            elemname = openmc.data.ATOMIC_SYMBOL[Z]
1✔
458
            for A, frac in A_vals:
1✔
459
                if A:
1✔
460
                    material.add_nuclide(f'{elemname}{A}', frac)
×
461
                else:
462
                    material.add_element(elemname, frac)
1✔
463

464
        material.set_density('g/cm3', nc_mat.getDensity())
1✔
465
        material._ncrystal_cfg = NCrystal.normaliseCfg(cfg)
1✔
466

467
        return material
1✔
468

469
    def add_volume_information(self, volume_calc):
12✔
470
        """Add volume information to a material.
471

472
        Parameters
473
        ----------
474
        volume_calc : openmc.VolumeCalculation
475
            Results from a stochastic volume calculation
476

477
        """
478
        if volume_calc.domain_type == 'material':
12✔
479
            if self.id in volume_calc.volumes:
12✔
480
                self._volume = volume_calc.volumes[self.id].n
12✔
481
                self._atoms = volume_calc.atoms[self.id]
12✔
482
            else:
483
                raise ValueError('No volume information found for material ID={}.'
×
484
                                 .format(self.id))
485
        else:
486
            raise ValueError(f'No volume information found for material ID={self.id}.')
×
487

488
    def set_density(self, units: str, density: float | None = None):
12✔
489
        """Set the density of the material
490

491
        Parameters
492
        ----------
493
        units : {'g/cm3', 'g/cc', 'kg/m3', 'atom/b-cm', 'atom/cm3', 'sum', 'macro'}
494
            Physical units of density.
495
        density : float, optional
496
            Value of the density. Must be specified unless units is given as
497
            'sum'.
498

499
        """
500

501
        cv.check_value('density units', units, DENSITY_UNITS)
12✔
502
        self._density_units = units
12✔
503

504
        if units == 'sum':
12✔
505
            if density is not None:
12✔
506
                msg = 'Density "{}" for Material ID="{}" is ignored ' \
×
507
                      'because the unit is "sum"'.format(density, self.id)
508
                warnings.warn(msg)
×
509
        else:
510
            if density is None:
12✔
511
                msg = 'Unable to set the density for Material ID="{}" ' \
×
512
                      'because a density value must be given when not using ' \
513
                      '"sum" unit'.format(self.id)
514
                raise ValueError(msg)
×
515

516
            cv.check_type(f'the density for Material ID="{self.id}"',
12✔
517
                          density, Real)
518
            self._density = density
12✔
519

520
    def add_nuclide(self, nuclide: str, percent: float, percent_type: str = 'ao'):
12✔
521
        """Add a nuclide to the material
522

523
        Parameters
524
        ----------
525
        nuclide : str
526
            Nuclide to add, e.g., 'Mo95'
527
        percent : float
528
            Atom or weight percent
529
        percent_type : {'ao', 'wo'}
530
            'ao' for atom percent and 'wo' for weight percent
531

532
        """
533
        cv.check_type('nuclide', nuclide, str)
12✔
534
        cv.check_type('percent', percent, Real)
12✔
535
        cv.check_value('percent type', percent_type, {'ao', 'wo'})
12✔
536
        cv.check_greater_than('percent', percent, 0, equality=True)
12✔
537

538
        if self._macroscopic is not None:
12✔
539
            msg = 'Unable to add a Nuclide to Material ID="{}" as a ' \
12✔
540
                  'macroscopic data-set has already been added'.format(self._id)
541
            raise ValueError(msg)
12✔
542

543
        if self._ncrystal_cfg is not None:
12✔
544
            raise ValueError("Cannot add nuclides to NCrystal material")
×
545

546
        # If nuclide name doesn't look valid, give a warning
547
        try:
12✔
548
            Z, _, _ = openmc.data.zam(nuclide)
12✔
549
        except ValueError as e:
12✔
550
            warnings.warn(str(e))
12✔
551
        else:
552
            # For actinides, have the material be depletable by default
553
            if Z >= 89:
12✔
554
                self.depletable = True
12✔
555

556
        self._nuclides.append(NuclideTuple(nuclide, percent, percent_type))
12✔
557

558
    def add_components(self, components: dict, percent_type: str = 'ao'):
12✔
559
        """ Add multiple elements or nuclides to a material
560

561
        .. versionadded:: 0.13.1
562

563
        Parameters
564
        ----------
565
        components : dict of str to float or dict
566
            Dictionary mapping element or nuclide names to their atom or weight
567
            percent. To specify enrichment of an element, the entry of
568
            ``components`` for that element must instead be a dictionary
569
            containing the keyword arguments as well as a value for
570
            ``'percent'``
571
        percent_type : {'ao', 'wo'}
572
            'ao' for atom percent and 'wo' for weight percent
573

574
        Examples
575
        --------
576
        >>> mat = openmc.Material()
577
        >>> components  = {'Li': {'percent': 1.0,
578
        >>>                       'enrichment': 60.0,
579
        >>>                       'enrichment_target': 'Li7'},
580
        >>>                'Fl': 1.0,
581
        >>>                'Be6': 0.5}
582
        >>> mat.add_components(components)
583

584
        """
585

586
        for component, params in components.items():
12✔
587
            cv.check_type('component', component, str)
12✔
588
            if isinstance(params, Real):
12✔
589
                params = {'percent': params}
12✔
590

591
            else:
592
                cv.check_type('params', params, dict)
12✔
593
                if 'percent' not in params:
12✔
594
                    raise ValueError("An entry in the dictionary does not have "
×
595
                                     "a required key: 'percent'")
596

597
            params['percent_type'] = percent_type
12✔
598

599
            # check if nuclide
600
            if not component.isalpha():
12✔
601
                self.add_nuclide(component, **params)
12✔
602
            else:
603
                self.add_element(component, **params)
12✔
604

605
    def remove_nuclide(self, nuclide: str):
12✔
606
        """Remove a nuclide from the material
607

608
        Parameters
609
        ----------
610
        nuclide : str
611
            Nuclide to remove
612

613
        """
614
        cv.check_type('nuclide', nuclide, str)
12✔
615

616
        # If the Material contains the Nuclide, delete it
617
        for nuc in reversed(self.nuclides):
12✔
618
            if nuclide == nuc.name:
12✔
619
                self.nuclides.remove(nuc)
12✔
620

621
    def remove_element(self, element):
12✔
622
        """Remove an element from the material
623

624
        .. versionadded:: 0.13.1
625

626
        Parameters
627
        ----------
628
        element : str
629
            Element to remove
630

631
        """
632
        cv.check_type('element', element, str)
12✔
633

634
        # If the Material contains the element, delete it
635
        for nuc in reversed(self.nuclides):
12✔
636
            element_name = re.split(r'\d+', nuc.name)[0]
12✔
637
            if element_name == element:
12✔
638
                self.nuclides.remove(nuc)
12✔
639

640
    def add_macroscopic(self, macroscopic: str):
12✔
641
        """Add a macroscopic to the material.  This will also set the
642
        density of the material to 1.0, unless it has been otherwise set,
643
        as a default for Macroscopic cross sections.
644

645
        Parameters
646
        ----------
647
        macroscopic : str
648
            Macroscopic to add
649

650
        """
651

652
        # Ensure no nuclides, elements, or sab are added since these would be
653
        # incompatible with macroscopics
654
        if self._nuclides or self._sab:
12✔
655
            msg = 'Unable to add a Macroscopic data set to Material ID="{}" ' \
12✔
656
                  'with a macroscopic value "{}" as an incompatible data ' \
657
                  'member (i.e., nuclide or S(a,b) table) ' \
658
                  'has already been added'.format(self._id, macroscopic)
659
            raise ValueError(msg)
12✔
660

661
        if not isinstance(macroscopic, str):
12✔
662
            msg = 'Unable to add a Macroscopic to Material ID="{}" with a ' \
×
663
                  'non-string value "{}"'.format(self._id, macroscopic)
664
            raise ValueError(msg)
×
665

666
        if self._macroscopic is None:
12✔
667
            self._macroscopic = macroscopic
12✔
668
        else:
669
            msg = 'Unable to add a Macroscopic to Material ID="{}". ' \
12✔
670
                  'Only one Macroscopic allowed per ' \
671
                  'Material.'.format(self._id)
672
            raise ValueError(msg)
12✔
673

674
        # Generally speaking, the density for a macroscopic object will
675
        # be 1.0. Therefore, lets set density to 1.0 so that the user
676
        # doesn't need to set it unless its needed.
677
        # Of course, if the user has already set a value of density,
678
        # then we will not override it.
679
        if self._density is None:
12✔
680
            self.set_density('macro', 1.0)
12✔
681

682
    def remove_macroscopic(self, macroscopic: str):
12✔
683
        """Remove a macroscopic from the material
684

685
        Parameters
686
        ----------
687
        macroscopic : str
688
            Macroscopic to remove
689

690
        """
691

692
        if not isinstance(macroscopic, str):
12✔
693
            msg = 'Unable to remove a Macroscopic "{}" in Material ID="{}" ' \
×
694
                  'since it is not a string'.format(self._id, macroscopic)
695
            raise ValueError(msg)
×
696

697
        # If the Material contains the Macroscopic, delete it
698
        if macroscopic == self._macroscopic:
12✔
699
            self._macroscopic = None
12✔
700

701
    def add_element(self, element: str, percent: float, percent_type: str = 'ao',
12✔
702
                    enrichment: float | None = None,
703
                    enrichment_target: str | None = None,
704
                    enrichment_type: str | None = None,
705
                    cross_sections: str | None = None):
706
        """Add a natural element to the material
707

708
        Parameters
709
        ----------
710
        element : str
711
            Element to add, e.g., 'Zr' or 'Zirconium'
712
        percent : float
713
            Atom or weight percent
714
        percent_type : {'ao', 'wo'}, optional
715
            'ao' for atom percent and 'wo' for weight percent. Defaults to atom
716
            percent.
717
        enrichment : float, optional
718
            Enrichment of an enrichment_target nuclide in percent (ao or wo).
719
            If enrichment_target is not supplied then it is enrichment for U235
720
            in weight percent. For example, input 4.95 for 4.95 weight percent
721
            enriched U.
722
            Default is None (natural composition).
723
        enrichment_target: str, optional
724
            Single nuclide name to enrich from a natural composition (e.g., 'O16')
725

726
            .. versionadded:: 0.12
727
        enrichment_type: {'ao', 'wo'}, optional
728
            'ao' for enrichment as atom percent and 'wo' for weight percent.
729
            Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment
730

731
            .. versionadded:: 0.12
732
        cross_sections : str, optional
733
            Location of cross_sections.xml file.
734

735
        Notes
736
        -----
737
        General enrichment procedure is allowed only for elements composed of
738
        two isotopes. If `enrichment_target` is given without `enrichment`
739
        natural composition is added to the material.
740

741
        """
742

743
        cv.check_type('nuclide', element, str)
12✔
744
        cv.check_type('percent', percent, Real)
12✔
745
        cv.check_greater_than('percent', percent, 0, equality=True)
12✔
746
        cv.check_value('percent type', percent_type, {'ao', 'wo'})
12✔
747

748
        # Make sure element name is just that
749
        if not element.isalpha():
12✔
750
            raise ValueError("Element name should be given by the "
×
751
                             "element's symbol or name, e.g., 'Zr', 'zirconium'")
752

753
        if self._ncrystal_cfg is not None:
12✔
754
            raise ValueError("Cannot add elements to NCrystal material")
×
755

756
        # Allow for element identifier to be given as a symbol or name
757
        if len(element) > 2:
12✔
758
            el = element.lower()
12✔
759
            element = openmc.data.ELEMENT_SYMBOL.get(el)
12✔
760
            if element is None:
12✔
761
                msg = f'Element name "{el}" not recognised'
12✔
762
                raise ValueError(msg)
12✔
763
        else:
764
            if element[0].islower():
12✔
765
                msg = f'Element name "{element}" should start with an uppercase letter'
12✔
766
                raise ValueError(msg)
12✔
767
            if len(element) == 2 and element[1].isupper():
12✔
768
                msg = f'Element name "{element}" should end with a lowercase letter'
12✔
769
                raise ValueError(msg)
12✔
770
            # skips the first entry of ATOMIC_SYMBOL which is n for neutron
771
            if element not in list(openmc.data.ATOMIC_SYMBOL.values())[1:]:
12✔
772
                msg = f'Element name "{element}" not recognised'
12✔
773
                raise ValueError(msg)
12✔
774

775
        if self._macroscopic is not None:
12✔
776
            msg = 'Unable to add an Element to Material ID="{}" as a ' \
12✔
777
                  'macroscopic data-set has already been added'.format(self._id)
778
            raise ValueError(msg)
12✔
779

780
        if enrichment is not None and enrichment_target is None:
12✔
781
            if not isinstance(enrichment, Real):
12✔
782
                msg = 'Unable to add an Element to Material ID="{}" with a ' \
×
783
                      'non-floating point enrichment value "{}"'\
784
                      .format(self._id, enrichment)
785
                raise ValueError(msg)
×
786

787
            elif element != 'U':
12✔
788
                msg = 'Unable to use enrichment for element {} which is not ' \
12✔
789
                      'uranium for Material ID="{}"'.format(element, self._id)
790
                raise ValueError(msg)
12✔
791

792
            # Check that the enrichment is in the valid range
793
            cv.check_less_than('enrichment', enrichment, 100./1.008)
12✔
794
            cv.check_greater_than('enrichment', enrichment, 0., equality=True)
12✔
795

796
            if enrichment > 5.0:
12✔
797
                msg = 'A uranium enrichment of {} was given for Material ID='\
×
798
                      '"{}". OpenMC assumes the U234/U235 mass ratio is '\
799
                      'constant at 0.008, which is only valid at low ' \
800
                      'enrichments. Consider setting the isotopic ' \
801
                      'composition manually for enrichments over 5%.'.\
802
                      format(enrichment, self._id)
803
                warnings.warn(msg)
×
804

805
        # Add naturally-occuring isotopes
806
        element = openmc.Element(element)
12✔
807
        for nuclide in element.expand(percent,
12✔
808
                                      percent_type,
809
                                      enrichment,
810
                                      enrichment_target,
811
                                      enrichment_type,
812
                                      cross_sections):
813
            self.add_nuclide(*nuclide)
12✔
814

815
    def add_elements_from_formula(self, formula: str, percent_type: str = 'ao',
12✔
816
                                  enrichment: float | None = None,
817
                                  enrichment_target: str | None = None,
818
                                  enrichment_type: str | None = None):
819
        """Add a elements from a chemical formula to the material.
820

821
        .. versionadded:: 0.12
822

823
        Parameters
824
        ----------
825
        formula : str
826
            Formula to add, e.g., 'C2O', 'C6H12O6', or (NH4)2SO4.
827
            Note this is case sensitive, elements must start with an uppercase
828
            character. Multiplier numbers must be integers.
829
        percent_type : {'ao', 'wo'}, optional
830
            'ao' for atom percent and 'wo' for weight percent. Defaults to atom
831
            percent.
832
        enrichment : float, optional
833
            Enrichment of an enrichment_target nuclide in percent (ao or wo).
834
            If enrichment_target is not supplied then it is enrichment for U235
835
            in weight percent. For example, input 4.95 for 4.95 weight percent
836
            enriched U. Default is None (natural composition).
837
        enrichment_target : str, optional
838
            Single nuclide name to enrich from a natural composition (e.g., 'O16')
839
        enrichment_type : {'ao', 'wo'}, optional
840
            'ao' for enrichment as atom percent and 'wo' for weight percent.
841
            Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment
842

843
        Notes
844
        -----
845
        General enrichment procedure is allowed only for elements composed of
846
        two isotopes. If `enrichment_target` is given without `enrichment`
847
        natural composition is added to the material.
848

849
        """
850
        cv.check_type('formula', formula, str)
12✔
851

852
        if '.' in formula:
12✔
853
            msg = 'Non-integer multiplier values are not accepted. The ' \
12✔
854
                  'input formula {} contains a "." character.'.format(formula)
855
            raise ValueError(msg)
12✔
856

857
        # Tokenizes the formula and check validity of tokens
858
        tokens = re.findall(r"([A-Z][a-z]*)(\d*)|(\()|(\))(\d*)", formula)
12✔
859
        for row in tokens:
12✔
860
            for token in row:
12✔
861
                if token.isalpha():
12✔
862
                    if token == "n" or token not in openmc.data.ATOMIC_NUMBER:
12✔
863
                        msg = f'Formula entry {token} not an element symbol.'
12✔
864
                        raise ValueError(msg)
12✔
865
                elif token not in ['(', ')', ''] and not token.isdigit():
12✔
866
                        msg = 'Formula must be made from a sequence of ' \
×
867
                              'element symbols, integers, and brackets. ' \
868
                              '{} is not an allowable entry.'.format(token)
869
                        raise ValueError(msg)
×
870

871
        # Checks that the number of opening and closing brackets are equal
872
        if formula.count('(') != formula.count(')'):
12✔
873
            msg = 'Number of opening and closing brackets is not equal ' \
12✔
874
                  'in the input formula {}.'.format(formula)
875
            raise ValueError(msg)
12✔
876

877
        # Checks that every part of the original formula has been tokenized
878
        for row in tokens:
12✔
879
            for token in row:
12✔
880
                formula = formula.replace(token, '', 1)
12✔
881
        if len(formula) != 0:
12✔
882
            msg = 'Part of formula was not successfully parsed as an ' \
12✔
883
                  'element symbol, bracket or integer. {} was not parsed.' \
884
                  .format(formula)
885
            raise ValueError(msg)
12✔
886

887
        # Works through the tokens building a stack
888
        mat_stack = [Counter()]
12✔
889
        for symbol, multi1, opening_bracket, closing_bracket, multi2 in tokens:
12✔
890
            if symbol:
12✔
891
                mat_stack[-1][symbol] += int(multi1 or 1)
12✔
892
            if opening_bracket:
12✔
893
                mat_stack.append(Counter())
12✔
894
            if closing_bracket:
12✔
895
                stack_top = mat_stack.pop()
12✔
896
                for symbol, value in stack_top.items():
12✔
897
                    mat_stack[-1][symbol] += int(multi2 or 1) * value
12✔
898

899
        # Normalizing percentages
900
        percents = mat_stack[0].values()
12✔
901
        norm_percents = [float(i) / sum(percents) for i in percents]
12✔
902
        elements = mat_stack[0].keys()
12✔
903

904
        # Adds each element and percent to the material
905
        for element, percent in zip(elements, norm_percents):
12✔
906
            if enrichment_target is not None and element == re.sub(r'\d+$', '', enrichment_target):
12✔
907
                self.add_element(element, percent, percent_type, enrichment,
12✔
908
                                 enrichment_target, enrichment_type)
909
            elif enrichment is not None and enrichment_target is None and element == 'U':
12✔
910
                self.add_element(element, percent, percent_type, enrichment)
×
911
            else:
912
                self.add_element(element, percent, percent_type)
12✔
913

914
    def add_s_alpha_beta(self, name: str, fraction: float = 1.0):
12✔
915
        r"""Add an :math:`S(\alpha,\beta)` table to the material
916

917
        Parameters
918
        ----------
919
        name : str
920
            Name of the :math:`S(\alpha,\beta)` table
921
        fraction : float
922
            The fraction of relevant nuclei that are affected by the
923
            :math:`S(\alpha,\beta)` table.  For example, if the material is a
924
            block of carbon that is 60% graphite and 40% amorphous then add a
925
            graphite :math:`S(\alpha,\beta)` table with fraction=0.6.
926

927
        """
928

929
        if self._macroscopic is not None:
12✔
930
            msg = 'Unable to add an S(a,b) table to Material ID="{}" as a ' \
×
931
                  'macroscopic data-set has already been added'.format(self._id)
932
            raise ValueError(msg)
×
933

934
        if not isinstance(name, str):
12✔
935
            msg = 'Unable to add an S(a,b) table to Material ID="{}" with a ' \
×
936
                        'non-string table name "{}"'.format(self._id, name)
937
            raise ValueError(msg)
×
938

939
        cv.check_type('S(a,b) fraction', fraction, Real)
12✔
940
        cv.check_greater_than('S(a,b) fraction', fraction, 0.0, True)
12✔
941
        cv.check_less_than('S(a,b) fraction', fraction, 1.0, True)
12✔
942
        self._sab.append((name, fraction))
12✔
943

944
    def make_isotropic_in_lab(self):
12✔
945
        self.isotropic = [x.name for x in self._nuclides]
12✔
946

947
    def get_elements(self) -> list[str]:
12✔
948
        """Returns all elements in the material
949

950
        .. versionadded:: 0.12
951

952
        Returns
953
        -------
954
        elements : list of str
955
            List of element names
956

957
        """
958

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

961
    def get_nuclides(self, element: str | None = None) -> list[str]:
12✔
962
        """Returns a list of all nuclides in the material, if the element
963
        argument is specified then just nuclides of that element are returned.
964

965
        Parameters
966
        ----------
967
        element : str
968
            Specifies the element to match when searching through the nuclides
969

970
            .. versionadded:: 0.13.2
971

972
        Returns
973
        -------
974
        nuclides : list of str
975
            List of nuclide names
976
        """
977

978
        matching_nuclides = []
12✔
979
        if element:
12✔
980
            for nuclide in self._nuclides:
12✔
981
                if re.split(r'(\d+)', nuclide.name)[0] == element:
12✔
982
                    if nuclide.name not in matching_nuclides:
12✔
983
                        matching_nuclides.append(nuclide.name)
12✔
984
        else:
985
            for nuclide in self._nuclides:
12✔
986
                if nuclide.name not in matching_nuclides:
12✔
987
                    matching_nuclides.append(nuclide.name)
12✔
988

989
        return matching_nuclides
12✔
990

991
    def get_nuclide_densities(self) -> dict[str, tuple]:
12✔
992
        """Returns all nuclides in the material and their densities
993

994
        Returns
995
        -------
996
        nuclides : dict
997
            Dictionary whose keys are nuclide names and values are 3-tuples of
998
            (nuclide, density percent, density percent type)
999

1000
        """
1001

1002
        nuclides = {}
12✔
1003

1004
        for nuclide in self._nuclides:
12✔
1005
            nuclides[nuclide.name] = nuclide
12✔
1006

1007
        return nuclides
12✔
1008

1009
    def get_nuclide_atom_densities(self, nuclide: str | None = None) -> dict[str, float]:
12✔
1010
        """Returns one or all nuclides in the material and their atomic
1011
        densities in units of atom/b-cm
1012

1013
        .. versionchanged:: 0.13.1
1014
            The values in the dictionary were changed from a tuple containing
1015
            the nuclide name and the density to just the density.
1016

1017
        Parameters
1018
        ----------
1019
        nuclides : str, optional
1020
            Nuclide for which atom density is desired. If not specified, the
1021
            atom density for each nuclide in the material is given.
1022

1023
            .. versionadded:: 0.13.2
1024

1025
        Returns
1026
        -------
1027
        nuclides : dict
1028
            Dictionary whose keys are nuclide names and values are densities in
1029
            [atom/b-cm]
1030

1031
        """
1032

1033
        sum_density = False
12✔
1034
        if self.density_units == 'sum':
12✔
1035
            sum_density = True
12✔
1036
            density = 0.
12✔
1037
        elif self.density_units == 'macro':
12✔
1038
            density = self.density
×
1039
        elif self.density_units == 'g/cc' or self.density_units == 'g/cm3':
12✔
1040
            density = -self.density
12✔
1041
        elif self.density_units == 'kg/m3':
12✔
1042
            density = -0.001 * self.density
×
1043
        elif self.density_units == 'atom/b-cm':
12✔
1044
            density = self.density
12✔
1045
        elif self.density_units == 'atom/cm3' or self.density_units == 'atom/cc':
12✔
1046
            density = 1.e-24 * self.density
12✔
1047

1048
        # For ease of processing split out nuc, nuc_density,
1049
        # and nuc_density_type into separate arrays
1050
        nucs = []
12✔
1051
        nuc_densities = []
12✔
1052
        nuc_density_types = []
12✔
1053

1054
        for nuc in self.nuclides:
12✔
1055
            nucs.append(nuc.name)
12✔
1056
            nuc_densities.append(nuc.percent)
12✔
1057
            nuc_density_types.append(nuc.percent_type)
12✔
1058

1059
        nucs = np.array(nucs)
12✔
1060
        nuc_densities = np.array(nuc_densities)
12✔
1061
        nuc_density_types = np.array(nuc_density_types)
12✔
1062

1063
        if sum_density:
12✔
1064
            density = np.sum(nuc_densities)
12✔
1065

1066
        percent_in_atom = np.all(nuc_density_types == 'ao')
12✔
1067
        density_in_atom = density > 0.
12✔
1068
        sum_percent = 0.
12✔
1069

1070
        # Convert the weight amounts to atomic amounts
1071
        if not percent_in_atom:
12✔
1072
            for n, nuc in enumerate(nucs):
12✔
1073
                nuc_densities[n] *= self.average_molar_mass / \
12✔
1074
                                    openmc.data.atomic_mass(nuc)
1075

1076
        # Now that we have the atomic amounts, lets finish calculating densities
1077
        sum_percent = np.sum(nuc_densities)
12✔
1078
        nuc_densities = nuc_densities / sum_percent
12✔
1079

1080
        # Convert the mass density to an atom density
1081
        if not density_in_atom:
12✔
1082
            density = -density / self.average_molar_mass * 1.e-24 \
12✔
1083
                      * openmc.data.AVOGADRO
1084

1085
        nuc_densities = density * nuc_densities
12✔
1086

1087
        nuclides = {}
12✔
1088
        for n, nuc in enumerate(nucs):
12✔
1089
            if nuclide is None or nuclide == nuc:
12✔
1090
                nuclides[nuc] = nuc_densities[n]
12✔
1091

1092
        return nuclides
12✔
1093

1094
    def get_element_atom_densities(self, element: str | None = None) -> dict[str, float]:
12✔
1095
        """Returns one or all elements in the material and their atomic
1096
        densities in units of atom/b-cm
1097

1098
        .. versionadded:: 0.15.1
1099

1100
        Parameters
1101
        ----------
1102
        element : str, optional
1103
            Element for which atom density is desired. If not specified, the
1104
            atom density for each element in the material is given.
1105

1106
        Returns
1107
        -------
1108
        elements : dict
1109
            Dictionary whose keys are element names and values are densities in
1110
            [atom/b-cm]
1111

1112
        """
1113
        if element is not None:
12✔
1114
            element = _get_element_symbol(element)
12✔
1115

1116
        nuc_densities = self.get_nuclide_atom_densities()
12✔
1117

1118
        # Initialize an empty dictionary for summed values
1119
        densities = {}
12✔
1120

1121
        # Accumulate densities for each nuclide
1122
        for nuclide, density in nuc_densities.items():
12✔
1123
            nuc_element = openmc.data.ATOMIC_SYMBOL[openmc.data.zam(nuclide)[0]]
12✔
1124
            if element is None or element == nuc_element:
12✔
1125
                if nuc_element not in densities:
12✔
1126
                    densities[nuc_element] = 0.0
12✔
1127
                densities[nuc_element] += float(density)
12✔
1128

1129
        # If specific element was requested, make sure it is present
1130
        if element is not None and element not in densities:
12✔
1131
                raise ValueError(f'Element {element} not found in material.')
12✔
1132

1133
        return densities
12✔
1134

1135

1136
    def get_activity(self, units: str = 'Bq/cm3', by_nuclide: bool = False,
12✔
1137
                     volume: float | None = None) -> dict[str, float] | float:
1138
        """Returns the activity of the material or for each nuclide in the
1139
        material in units of [Bq], [Bq/g] or [Bq/cm3].
1140

1141
        .. versionadded:: 0.13.1
1142

1143
        Parameters
1144
        ----------
1145
        units : {'Bq', 'Bq/g', 'Bq/cm3'}
1146
            Specifies the type of activity to return, options include total
1147
            activity [Bq], specific [Bq/g] or volumetric activity [Bq/cm3].
1148
            Default is volumetric activity [Bq/cm3].
1149
        by_nuclide : bool
1150
            Specifies if the activity should be returned for the material as a
1151
            whole or per nuclide. Default is False.
1152
        volume : float, optional
1153
            Volume of the material. If not passed, defaults to using the
1154
            :attr:`Material.volume` attribute.
1155

1156
            .. versionadded:: 0.13.3
1157

1158
        Returns
1159
        -------
1160
        Union[dict, float]
1161
            If by_nuclide is True then a dictionary whose keys are nuclide
1162
            names and values are activity is returned. Otherwise the activity
1163
            of the material is returned as a float.
1164
        """
1165

1166
        cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/cm3'})
12✔
1167
        cv.check_type('by_nuclide', by_nuclide, bool)
12✔
1168

1169
        if units == 'Bq':
12✔
1170
            multiplier = volume if volume is not None else self.volume
12✔
1171
        elif units == 'Bq/cm3':
12✔
1172
            multiplier = 1
12✔
1173
        elif units == 'Bq/g':
12✔
1174
            multiplier = 1.0 / self.get_mass_density()
12✔
1175

1176
        activity = {}
12✔
1177
        for nuclide, atoms_per_bcm in self.get_nuclide_atom_densities().items():
12✔
1178
            inv_seconds = openmc.data.decay_constant(nuclide)
12✔
1179
            activity[nuclide] = inv_seconds * 1e24 * atoms_per_bcm * multiplier
12✔
1180

1181
        return activity if by_nuclide else sum(activity.values())
12✔
1182

1183
    def get_decay_heat(self, units: str = 'W', by_nuclide: bool = False,
12✔
1184
                       volume: float | None = None) -> dict[str, float] | float:
1185
        """Returns the decay heat of the material or for each nuclide in the
1186
        material in units of [W], [W/g] or [W/cm3].
1187

1188
        .. versionadded:: 0.13.3
1189

1190
        Parameters
1191
        ----------
1192
        units : {'W', 'W/g', 'W/cm3'}
1193
            Specifies the units of decay heat to return. Options include total
1194
            heat [W], specific [W/g] or volumetric heat [W/cm3].
1195
            Default is total heat [W].
1196
        by_nuclide : bool
1197
            Specifies if the decay heat should be returned for the material as a
1198
            whole or per nuclide. Default is False.
1199
        volume : float, optional
1200
            Volume of the material. If not passed, defaults to using the
1201
            :attr:`Material.volume` attribute.
1202

1203
            .. versionadded:: 0.13.3
1204

1205
        Returns
1206
        -------
1207
        Union[dict, float]
1208
            If `by_nuclide` is True then a dictionary whose keys are nuclide
1209
            names and values are decay heat is returned. Otherwise the decay heat
1210
            of the material is returned as a float.
1211
        """
1212

1213
        cv.check_value('units', units, {'W', 'W/g', 'W/cm3'})
12✔
1214
        cv.check_type('by_nuclide', by_nuclide, bool)
12✔
1215

1216
        if units == 'W':
12✔
1217
            multiplier = volume if volume is not None else self.volume
12✔
1218
        elif units == 'W/cm3':
12✔
1219
            multiplier = 1
12✔
1220
        elif units == 'W/g':
12✔
1221
            multiplier = 1.0 / self.get_mass_density()
12✔
1222

1223
        decayheat = {}
12✔
1224
        for nuclide, atoms_per_bcm in self.get_nuclide_atom_densities().items():
12✔
1225
            decay_erg = openmc.data.decay_energy(nuclide)
12✔
1226
            inv_seconds = openmc.data.decay_constant(nuclide)
12✔
1227
            decay_erg *= openmc.data.JOULE_PER_EV
12✔
1228
            decayheat[nuclide] = inv_seconds * decay_erg * 1e24 * atoms_per_bcm * multiplier
12✔
1229

1230
        return decayheat if by_nuclide else sum(decayheat.values())
12✔
1231

1232
    def get_nuclide_atoms(self, volume: float | None = None) -> dict[str, float]:
12✔
1233
        """Return number of atoms of each nuclide in the material
1234

1235
        .. versionadded:: 0.13.1
1236

1237
        Parameters
1238
        ----------
1239
        volume : float, optional
1240
            Volume of the material. If not passed, defaults to using the
1241
            :attr:`Material.volume` attribute.
1242

1243
            .. versionadded:: 0.13.3
1244

1245
        Returns
1246
        -------
1247
        dict
1248
            Dictionary whose keys are nuclide names and values are number of
1249
            atoms present in the material.
1250

1251
        """
1252
        if volume is None:
12✔
1253
            volume = self.volume
12✔
1254
        if volume is None:
12✔
1255
            raise ValueError("Volume must be set in order to determine atoms.")
×
1256
        atoms = {}
12✔
1257
        for nuclide, atom_per_bcm in self.get_nuclide_atom_densities().items():
12✔
1258
            atoms[nuclide] = 1.0e24 * atom_per_bcm * volume
12✔
1259
        return atoms
12✔
1260

1261
    def get_mass_density(self, nuclide: str | None = None) -> float:
12✔
1262
        """Return mass density of one or all nuclides
1263

1264
        Parameters
1265
        ----------
1266
        nuclides : str, optional
1267
            Nuclide for which density is desired. If not specified, the density
1268
            for the entire material is given.
1269

1270
        Returns
1271
        -------
1272
        float
1273
            Density of the nuclide/material in [g/cm^3]
1274

1275
        """
1276
        mass_density = 0.0
12✔
1277
        for nuc, atoms_per_bcm in self.get_nuclide_atom_densities(nuclide=nuclide).items():
12✔
1278
            density_i = 1e24 * atoms_per_bcm * openmc.data.atomic_mass(nuc) \
12✔
1279
                        / openmc.data.AVOGADRO
1280
            mass_density += density_i
12✔
1281
        return mass_density
12✔
1282

1283
    def get_mass(self, nuclide: str | None = None, volume: float | None = None) -> float:
12✔
1284
        """Return mass of one or all nuclides.
1285

1286
        Note that this method requires that the :attr:`Material.volume` has
1287
        already been set.
1288

1289
        Parameters
1290
        ----------
1291
        nuclides : str, optional
1292
            Nuclide for which mass is desired. If not specified, the density
1293
            for the entire material is given.
1294
        volume : float, optional
1295
            Volume of the material. If not passed, defaults to using the
1296
            :attr:`Material.volume` attribute.
1297

1298
            .. versionadded:: 0.13.3
1299

1300

1301
        Returns
1302
        -------
1303
        float
1304
            Mass of the nuclide/material in [g]
1305

1306
        """
1307
        if volume is None:
12✔
1308
            volume = self.volume
12✔
1309
        if volume is None:
12✔
1310
            raise ValueError("Volume must be set in order to determine mass.")
×
1311
        return volume*self.get_mass_density(nuclide)
12✔
1312

1313
    def clone(self, memo: dict | None = None) -> Material:
12✔
1314
        """Create a copy of this material with a new unique ID.
1315

1316
        Parameters
1317
        ----------
1318
        memo : dict or None
1319
            A nested dictionary of previously cloned objects. This parameter
1320
            is used internally and should not be specified by the user.
1321

1322
        Returns
1323
        -------
1324
        clone : openmc.Material
1325
            The clone of this material
1326

1327
        """
1328

1329
        if memo is None:
12✔
1330
            memo = {}
12✔
1331

1332
        # If no nemoize'd clone exists, instantiate one
1333
        if self not in memo:
12✔
1334
            # Temporarily remove paths -- this is done so that when the clone is
1335
            # made, it doesn't create a copy of the paths (which are specific to
1336
            # an instance)
1337
            paths = self._paths
12✔
1338
            self._paths = None
12✔
1339

1340
            clone = deepcopy(self)
12✔
1341
            clone.id = None
12✔
1342
            clone._num_instances = None
12✔
1343

1344
            # Restore paths on original instance
1345
            self._paths = paths
12✔
1346

1347
            # Memoize the clone
1348
            memo[self] = clone
12✔
1349

1350
        return memo[self]
12✔
1351

1352
    def _get_nuclide_xml(self, nuclide: NuclideTuple) -> ET.Element:
12✔
1353
        xml_element = ET.Element("nuclide")
12✔
1354
        xml_element.set("name", nuclide.name)
12✔
1355

1356
        # Prevent subnormal numbers from being written to XML, which causes an
1357
        # exception on the C++ side when calling std::stod
1358
        val = nuclide.percent
12✔
1359
        if abs(val) < _SMALLEST_NORMAL:
12✔
1360
            val = 0.0
12✔
1361

1362
        if nuclide.percent_type == 'ao':
12✔
1363
            xml_element.set("ao", str(val))
12✔
1364
        else:
1365
            xml_element.set("wo", str(val))
12✔
1366

1367
        return xml_element
12✔
1368

1369
    def _get_macroscopic_xml(self, macroscopic: str) -> ET.Element:
12✔
1370
        xml_element = ET.Element("macroscopic")
12✔
1371
        xml_element.set("name", macroscopic)
12✔
1372

1373
        return xml_element
12✔
1374

1375
    def _get_nuclides_xml(
12✔
1376
            self, nuclides: Iterable[NuclideTuple],
1377
            nuclides_to_ignore: Iterable[str] | None = None)-> list[ET.Element]:
1378
        xml_elements = []
12✔
1379

1380
        # Remove any nuclides to ignore from the XML export
1381
        if nuclides_to_ignore:
12✔
1382
            nuclides = [nuclide for nuclide in nuclides if nuclide.name not in nuclides_to_ignore]
12✔
1383

1384
        xml_elements = [self._get_nuclide_xml(nuclide) for nuclide in nuclides]
12✔
1385

1386
        return xml_elements
12✔
1387

1388
    def to_xml_element(
12✔
1389
            self, nuclides_to_ignore: Iterable[str] | None = None) -> ET.Element:
1390
        """Return XML representation of the material
1391

1392
        Parameters
1393
        ----------
1394
        nuclides_to_ignore : list of str
1395
            Nuclides to ignore when exporting to XML.
1396

1397
        Returns
1398
        -------
1399
        element : lxml.etree._Element
1400
            XML element containing material data
1401

1402
        """
1403

1404
        # Create Material XML element
1405
        element = ET.Element("material")
12✔
1406
        element.set("id", str(self._id))
12✔
1407

1408
        if len(self._name) > 0:
12✔
1409
            element.set("name", str(self._name))
12✔
1410

1411
        if self._depletable:
12✔
1412
            element.set("depletable", "true")
12✔
1413

1414
        if self._volume:
12✔
1415
            element.set("volume", str(self._volume))
12✔
1416

1417
        if self._ncrystal_cfg:
12✔
1418
            if self._sab:
1✔
1419
                raise ValueError("NCrystal materials are not compatible with S(a,b).")
×
1420
            if self._macroscopic is not None:
1✔
1421
                raise ValueError("NCrystal materials are not compatible with macroscopic cross sections.")
×
1422

1423
            element.set("cfg", str(self._ncrystal_cfg))
1✔
1424

1425
        # Create temperature XML subelement
1426
        if self.temperature is not None:
12✔
1427
            element.set("temperature", str(self.temperature))
12✔
1428

1429
        # Create density XML subelement
1430
        if self._density is not None or self._density_units == 'sum':
12✔
1431
            subelement = ET.SubElement(element, "density")
12✔
1432
            if self._density_units != 'sum':
12✔
1433
                subelement.set("value", str(self._density))
12✔
1434
            subelement.set("units", self._density_units)
12✔
1435
        else:
1436
            raise ValueError(f'Density has not been set for material {self.id}!')
×
1437

1438
        if self._macroscopic is None:
12✔
1439
            # Create nuclide XML subelements
1440
            subelements = self._get_nuclides_xml(self._nuclides,
12✔
1441
                                                 nuclides_to_ignore=nuclides_to_ignore)
1442
            for subelement in subelements:
12✔
1443
                element.append(subelement)
12✔
1444
        else:
1445
            # Create macroscopic XML subelements
1446
            subelement = self._get_macroscopic_xml(self._macroscopic)
12✔
1447
            element.append(subelement)
12✔
1448

1449
        if self._sab:
12✔
1450
            for sab in self._sab:
12✔
1451
                subelement = ET.SubElement(element, "sab")
12✔
1452
                subelement.set("name", sab[0])
12✔
1453
                if sab[1] != 1.0:
12✔
1454
                    subelement.set("fraction", str(sab[1]))
12✔
1455

1456
        if self._isotropic:
12✔
1457
            subelement = ET.SubElement(element, "isotropic")
12✔
1458
            subelement.text = ' '.join(self._isotropic)
12✔
1459

1460
        return element
12✔
1461

1462
    @classmethod
12✔
1463
    def mix_materials(cls, materials, fracs: Iterable[float],
12✔
1464
                      percent_type: str = 'ao', name: str | None = None) -> Material:
1465
        """Mix materials together based on atom, weight, or volume fractions
1466

1467
        .. versionadded:: 0.12
1468

1469
        Parameters
1470
        ----------
1471
        materials : Iterable of openmc.Material
1472
            Materials to combine
1473
        fracs : Iterable of float
1474
            Fractions of each material to be combined
1475
        percent_type : {'ao', 'wo', 'vo'}
1476
            Type of percentage, must be one of 'ao', 'wo', or 'vo', to signify atom
1477
            percent (molar percent), weight percent, or volume percent,
1478
            optional. Defaults to 'ao'
1479
        name : str
1480
            The name for the new material, optional. Defaults to concatenated
1481
            names of input materials with percentages indicated inside
1482
            parentheses.
1483

1484
        Returns
1485
        -------
1486
        openmc.Material
1487
            Mixture of the materials
1488

1489
        """
1490

1491
        cv.check_type('materials', materials, Iterable, Material)
12✔
1492
        cv.check_type('fracs', fracs, Iterable, Real)
12✔
1493
        cv.check_value('percent type', percent_type, {'ao', 'wo', 'vo'})
12✔
1494

1495
        fracs = np.asarray(fracs)
12✔
1496
        void_frac = 1. - np.sum(fracs)
12✔
1497

1498
        # Warn that fractions don't add to 1, set remainder to void, or raise
1499
        # an error if percent_type isn't 'vo'
1500
        if not np.isclose(void_frac, 0.):
12✔
1501
            if percent_type in ('ao', 'wo'):
12✔
1502
                msg = ('A non-zero void fraction is not acceptable for '
×
1503
                       'percent_type: {}'.format(percent_type))
1504
                raise ValueError(msg)
×
1505
            else:
1506
                msg = ('Warning: sum of fractions do not add to 1, void '
12✔
1507
                       'fraction set to {}'.format(void_frac))
1508
                warnings.warn(msg)
12✔
1509

1510
        # Calculate appropriate weights which are how many cc's of each
1511
        # material are found in 1cc of the composite material
1512
        amms = np.asarray([mat.average_molar_mass for mat in materials])
12✔
1513
        mass_dens = np.asarray([mat.get_mass_density() for mat in materials])
12✔
1514
        if percent_type == 'ao':
12✔
1515
            wgts = fracs * amms / mass_dens
12✔
1516
            wgts /= np.sum(wgts)
12✔
1517
        elif percent_type == 'wo':
12✔
1518
            wgts = fracs / mass_dens
12✔
1519
            wgts /= np.sum(wgts)
12✔
1520
        elif percent_type == 'vo':
12✔
1521
            wgts = fracs
12✔
1522

1523
        # If any of the involved materials contain S(a,b) tables raise an error
1524
        sab_names = set(sab[0] for mat in materials for sab in mat._sab)
12✔
1525
        if sab_names:
12✔
1526
            msg = ('Currently we do not support mixing materials containing '
×
1527
                   'S(a,b) tables')
1528
            raise NotImplementedError(msg)
×
1529

1530
        # Add nuclide densities weighted by appropriate fractions
1531
        nuclides_per_cc = defaultdict(float)
12✔
1532
        mass_per_cc = defaultdict(float)
12✔
1533
        for mat, wgt in zip(materials, wgts):
12✔
1534
            for nuc, atoms_per_bcm in mat.get_nuclide_atom_densities().items():
12✔
1535
                nuc_per_cc = wgt*1.e24*atoms_per_bcm
12✔
1536
                nuclides_per_cc[nuc] += nuc_per_cc
12✔
1537
                mass_per_cc[nuc] += nuc_per_cc*openmc.data.atomic_mass(nuc) / \
12✔
1538
                                    openmc.data.AVOGADRO
1539

1540
        # Create the new material with the desired name
1541
        if name is None:
12✔
1542
            name = '-'.join([f'{m.name}({f})' for m, f in
12✔
1543
                             zip(materials, fracs)])
1544
        new_mat = cls(name=name)
12✔
1545

1546
        # Compute atom fractions of nuclides and add them to the new material
1547
        tot_nuclides_per_cc = np.sum([dens for dens in nuclides_per_cc.values()])
12✔
1548
        for nuc, atom_dens in nuclides_per_cc.items():
12✔
1549
            new_mat.add_nuclide(nuc, atom_dens/tot_nuclides_per_cc, 'ao')
12✔
1550

1551
        # Compute mass density for the new material and set it
1552
        new_density = np.sum([dens for dens in mass_per_cc.values()])
12✔
1553
        new_mat.set_density('g/cm3', new_density)
12✔
1554

1555
        # If any of the involved materials is depletable, the new material is
1556
        # depletable
1557
        new_mat.depletable = any(mat.depletable for mat in materials)
12✔
1558

1559
        return new_mat
12✔
1560

1561
    @classmethod
12✔
1562
    def from_xml_element(cls, elem: ET.Element) -> Material:
12✔
1563
        """Generate material from an XML element
1564

1565
        Parameters
1566
        ----------
1567
        elem : lxml.etree._Element
1568
            XML element
1569

1570
        Returns
1571
        -------
1572
        openmc.Material
1573
            Material generated from XML element
1574

1575
        """
1576
        mat_id = int(elem.get('id'))
12✔
1577
        # Add NCrystal material from cfg string
1578
        if "cfg" in elem.attrib:
12✔
1579
            cfg = elem.get("cfg")
1✔
1580
            return Material.from_ncrystal(cfg, material_id=mat_id)
1✔
1581

1582
        mat = cls(mat_id)
12✔
1583
        mat.name = elem.get('name')
12✔
1584

1585
        if "temperature" in elem.attrib:
12✔
1586
            mat.temperature = float(elem.get("temperature"))
12✔
1587

1588
        if 'volume' in elem.attrib:
12✔
1589
            mat.volume = float(elem.get('volume'))
12✔
1590

1591
        # Get each nuclide
1592
        for nuclide in elem.findall('nuclide'):
12✔
1593
            name = nuclide.attrib['name']
12✔
1594
            if 'ao' in nuclide.attrib:
12✔
1595
                mat.add_nuclide(name, float(nuclide.attrib['ao']))
12✔
1596
            elif 'wo' in nuclide.attrib:
12✔
1597
                mat.add_nuclide(name, float(nuclide.attrib['wo']), 'wo')
12✔
1598

1599
        # Get depletable attribute
1600
        mat.depletable = elem.get('depletable') in ('true', '1')
12✔
1601

1602
        # Get each S(a,b) table
1603
        for sab in elem.findall('sab'):
12✔
1604
            fraction = float(sab.get('fraction', 1.0))
12✔
1605
            mat.add_s_alpha_beta(sab.get('name'), fraction)
12✔
1606

1607
        # Get total material density
1608
        density = elem.find('density')
12✔
1609
        units = density.get('units')
12✔
1610
        if units == 'sum':
12✔
1611
            mat.set_density(units)
12✔
1612
        else:
1613
            value = float(density.get('value'))
12✔
1614
            mat.set_density(units, value)
12✔
1615

1616
        # Check for isotropic scattering nuclides
1617
        isotropic = elem.find('isotropic')
12✔
1618
        if isotropic is not None:
12✔
1619
            mat.isotropic = isotropic.text.split()
12✔
1620

1621
        return mat
12✔
1622

1623

1624
class Materials(cv.CheckedList):
12✔
1625
    """Collection of Materials used for an OpenMC simulation.
1626

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

1631
    >>> fuel = openmc.Material()
1632
    >>> clad = openmc.Material()
1633
    >>> water = openmc.Material()
1634
    >>> m = openmc.Materials([fuel])
1635
    >>> m.append(water)
1636
    >>> m += [clad]
1637

1638
    Parameters
1639
    ----------
1640
    materials : Iterable of openmc.Material
1641
        Materials to add to the collection
1642

1643
    Attributes
1644
    ----------
1645
    cross_sections : str or path-like
1646
        Indicates the path to an XML cross section listing file (usually named
1647
        cross_sections.xml). If it is not set, the
1648
        :envvar:`OPENMC_CROSS_SECTIONS` environment variable will be used for
1649
        continuous-energy calculations and :envvar:`OPENMC_MG_CROSS_SECTIONS`
1650
        will be used for multi-group calculations to find the path to the HDF5
1651
        cross section file.
1652

1653
    """
1654

1655
    def __init__(self, materials=None):
12✔
1656
        super().__init__(Material, 'materials collection')
12✔
1657
        self._cross_sections = None
12✔
1658

1659
        if materials is not None:
12✔
1660
            self += materials
12✔
1661

1662
    @property
12✔
1663
    def cross_sections(self) -> Path | None:
12✔
1664
        return self._cross_sections
12✔
1665

1666
    @cross_sections.setter
12✔
1667
    def cross_sections(self, cross_sections):
12✔
1668
        if cross_sections is not None:
12✔
1669
            self._cross_sections = input_path(cross_sections)
12✔
1670

1671
    def append(self, material):
12✔
1672
        """Append material to collection
1673

1674
        Parameters
1675
        ----------
1676
        material : openmc.Material
1677
            Material to append
1678

1679
        """
1680
        super().append(material)
12✔
1681

1682
    def insert(self, index: int, material):
12✔
1683
        """Insert material before index
1684

1685
        Parameters
1686
        ----------
1687
        index : int
1688
            Index in list
1689
        material : openmc.Material
1690
            Material to insert
1691

1692
        """
1693
        super().insert(index, material)
×
1694

1695
    def make_isotropic_in_lab(self):
12✔
1696
        for material in self:
12✔
1697
            material.make_isotropic_in_lab()
12✔
1698

1699
    def _write_xml(self, file, header=True, level=0, spaces_per_level=2,
12✔
1700
                   trailing_indent=True, nuclides_to_ignore=None):
1701
        """Writes XML content of the materials to an open file handle.
1702

1703
        Parameters
1704
        ----------
1705
        file : IOTextWrapper
1706
            Open file handle to write content into.
1707
        header : bool
1708
            Whether or not to write the XML header
1709
        level : int
1710
            Indentation level of materials element
1711
        spaces_per_level : int
1712
            Number of spaces per indentation
1713
        trailing_indentation : bool
1714
            Whether or not to write a trailing indentation for the materials element
1715
        nuclides_to_ignore : list of str
1716
            Nuclides to ignore when exporting to XML.
1717

1718
        """
1719
        indentation = level*spaces_per_level*' '
12✔
1720
        # Write the header and the opening tag for the root element.
1721
        if header:
12✔
1722
            file.write("<?xml version='1.0' encoding='utf-8'?>\n")
12✔
1723
        file.write(indentation+'<materials>\n')
12✔
1724

1725
        # Write the <cross_sections> element.
1726
        if self.cross_sections is not None:
12✔
1727
            element = ET.Element('cross_sections')
12✔
1728
            element.text = str(self.cross_sections)
12✔
1729
            clean_indentation(element, level=level+1)
12✔
1730
            element.tail = element.tail.strip(' ')
12✔
1731
            file.write((level+1)*spaces_per_level*' ')
12✔
1732
            reorder_attributes(element)  # TODO: Remove when support is Python 3.8+
12✔
1733
            file.write(ET.tostring(element, encoding="unicode"))
12✔
1734

1735
        # Write the <material> elements.
1736
        for material in sorted(self, key=lambda x: x.id):
12✔
1737
            element = material.to_xml_element(nuclides_to_ignore=nuclides_to_ignore)
12✔
1738
            clean_indentation(element, level=level+1)
12✔
1739
            element.tail = element.tail.strip(' ')
12✔
1740
            file.write((level+1)*spaces_per_level*' ')
12✔
1741
            reorder_attributes(element)  # TODO: Remove when support is Python 3.8+
12✔
1742
            file.write(ET.tostring(element, encoding="unicode"))
12✔
1743

1744
        # Write the closing tag for the root element.
1745
        file.write(indentation+'</materials>\n')
12✔
1746

1747
        # Write a trailing indentation for the next element
1748
        # at this level if needed
1749
        if trailing_indent:
12✔
1750
            file.write(indentation)
12✔
1751

1752
    def export_to_xml(self, path: PathLike = 'materials.xml',
12✔
1753
                      nuclides_to_ignore: Iterable[str] | None = None):
1754
        """Export material collection to an XML file.
1755

1756
        Parameters
1757
        ----------
1758
        path : str
1759
            Path to file to write. Defaults to 'materials.xml'.
1760
        nuclides_to_ignore : list of str
1761
            Nuclides to ignore when exporting to XML.
1762

1763
        """
1764
        # Check if path is a directory
1765
        p = Path(path)
12✔
1766
        if p.is_dir():
12✔
1767
            p /= 'materials.xml'
12✔
1768

1769
        # Write materials to the file one-at-a-time.  This significantly reduces
1770
        # memory demand over allocating a complete ElementTree and writing it in
1771
        # one go.
1772
        with open(str(p), 'w', encoding='utf-8',
12✔
1773
                  errors='xmlcharrefreplace') as fh:
1774
            self._write_xml(fh, nuclides_to_ignore=nuclides_to_ignore)
12✔
1775

1776
    @classmethod
12✔
1777
    def from_xml_element(cls, elem) -> Materials:
12✔
1778
        """Generate materials collection from XML file
1779

1780
        Parameters
1781
        ----------
1782
        elem : lxml.etree._Element
1783
            XML element
1784

1785
        Returns
1786
        -------
1787
        openmc.Materials
1788
            Materials collection
1789

1790
        """
1791
        # Generate each material
1792
        materials = cls()
12✔
1793
        for material in elem.findall('material'):
12✔
1794
            materials.append(Material.from_xml_element(material))
12✔
1795

1796
        # Check for cross sections settings
1797
        xs = elem.find('cross_sections')
12✔
1798
        if xs is not None:
12✔
1799
            materials.cross_sections = xs.text
12✔
1800

1801
        return materials
12✔
1802

1803
    @classmethod
12✔
1804
    def from_xml(cls, path: PathLike = 'materials.xml') -> Materials:
12✔
1805
        """Generate materials collection from XML file
1806

1807
        Parameters
1808
        ----------
1809
        path : str
1810
            Path to materials XML file
1811

1812
        Returns
1813
        -------
1814
        openmc.Materials
1815
            Materials collection
1816

1817
        """
1818
        parser = ET.XMLParser(huge_tree=True)
12✔
1819
        tree = ET.parse(path, parser=parser)
12✔
1820
        root = tree.getroot()
12✔
1821

1822
        return cls.from_xml_element(root)
12✔
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