-
Notifications
You must be signed in to change notification settings - Fork 658
Expand file tree
/
Copy pathmaterial.py
More file actions
2408 lines (1964 loc) · 89.6 KB
/
Copy pathmaterial.py
File metadata and controls
2408 lines (1964 loc) · 89.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from __future__ import annotations
from collections import defaultdict, namedtuple, Counter
from collections.abc import Iterable
from copy import deepcopy
from functools import reduce
from numbers import Real
from pathlib import Path
import re
import sys
import tempfile
from typing import TYPE_CHECKING, Literal, Sequence, Dict
import warnings
import lxml.etree as ET
import numpy as np
import h5py
import openmc
import openmc.data
import openmc.checkvalue as cv
from ._xml import clean_indentation, get_elem_list, get_text
from .mixin import IDManagerMixin
from .utility_funcs import input_path
from . import waste
from openmc.checkvalue import PathLike
from openmc.stats import Univariate, Discrete, Mixture, Tabular
from openmc.data.data import _get_element_symbol, JOULE_PER_EV
from openmc.data.function import Tabulated1D
from openmc.data import mass_energy_absorption_coefficient, dose_coefficients
if TYPE_CHECKING:
from openmc.deplete import Chain
# Units for density supported by OpenMC
DENSITY_UNITS = ('g/cm3', 'g/cc', 'kg/m3', 'atom/b-cm', 'atom/cm3', 'sum',
'macro')
# Smallest normalized floating point number
_SMALLEST_NORMAL = sys.float_info.min
_BECQUEREL_PER_CURIE = 3.7e10
NuclideTuple = namedtuple('NuclideTuple', ['name', 'percent', 'percent_type'])
class Material(IDManagerMixin):
"""A material composed of a collection of nuclides/elements.
To create a material, one should create an instance of this class, add
nuclides or elements with :meth:`Material.add_nuclide` or
:meth:`Material.add_element`, respectively, and set the total material
density with :meth:`Material.set_density()`. Alternatively, you can use
:meth:`Material.add_components()` to pass a dictionary containing all the
component information. The material can then be assigned to a cell using the
:attr:`Cell.fill` attribute.
Parameters
----------
material_id : int, optional
Unique identifier for the material. If not specified, an identifier will
automatically be assigned.
name : str, optional
Name of the material. If not specified, the name will be the empty
string.
temperature : float, optional
Temperature of the material in Kelvin. If not specified, the material
inherits the default temperature applied to the model.
density : float, optional
Density of the material (units defined separately)
density_units : str
Units used for `density`. Can be one of 'g/cm3', 'g/cc', 'kg/m3',
'atom/b-cm', 'atom/cm3', 'sum', or 'macro'. The 'macro' unit only
applies in the case of a multi-group calculation. Defaults to 'sum'.
depletable : bool, optional
Indicate whether the material is depletable. Defaults to False.
volume : float, optional
Volume of the material in cm^3. This can either be set manually or
calculated in a stochastic volume calculation and added via the
:meth:`Material.add_volume_information` method.
components : dict of str to float or dict
Dictionary mapping element or nuclide names to their atom or weight
percent. To specify enrichment of an element, the entry of
``components`` for that element must instead be a dictionary containing
the keyword arguments as well as a value for ``'percent'``
percent_type : {'ao', 'wo'}
Whether the values in `components` should be interpreted as atom percent
('ao') or weight percent ('wo').
Attributes
----------
id : int
Unique identifier for the material
temperature : float
Temperature of the material in Kelvin.
density : float
Density of the material (units defined separately)
density_units : str
Units used for `density`. Can be one of 'g/cm3', 'g/cc', 'kg/m3',
'atom/b-cm', 'atom/cm3', 'sum', or 'macro'. The 'macro' unit only
applies in the case of a multi-group calculation.
depletable : bool
Indicate whether the material is depletable.
nuclides : list of namedtuple
List in which each item is a namedtuple consisting of a nuclide string,
the percent density, and the percent type ('ao' or 'wo'). The namedtuple
has field names ``name``, ``percent``, and ``percent_type``.
isotropic : list of str
Nuclides for which elastic scattering should be treated as though it
were isotropic in the laboratory system.
average_molar_mass : float
The average molar mass of nuclides in the material in units of grams per
mol. For example, UO2 with 3 nuclides will have an average molar mass
of 270 / 3 = 90 g / mol.
volume : float
Volume of the material in cm^3. This can either be set manually or
calculated in a stochastic volume calculation and added via the
:meth:`Material.add_volume_information` method.
paths : list of str
The paths traversed through the CSG tree to reach each material
instance. This property is initialized by calling the
:meth:`Geometry.determine_paths` method.
num_instances : int
The number of instances of this material throughout the geometry. This
property is initialized by calling the :meth:`Geometry.determine_paths`
method.
fissionable_mass : float
Mass of fissionable nuclides in the material in [g]. Requires that the
:attr:`volume` attribute is set.
ncrystal_cfg : str
NCrystal configuration string
.. versionadded:: 0.13.3
"""
next_id = 1
used_ids = set()
def __init__(
self,
material_id: int | None = None,
name: str = "",
temperature: float | None = None,
density: float | None = None,
density_units: str = "sum",
depletable: bool | None = False,
volume: float | None = None,
components: dict | None = None,
percent_type: str = "ao",
):
# Initialize class attributes
self.id = material_id
self.name = name
self.temperature = temperature
self._density = None
self._density_units = density_units
self._depletable = depletable
self._paths = None
self._num_instances = None
self._volume = volume
self._atoms = {}
self._isotropic = []
self._ncrystal_cfg = None
# A list of tuples (nuclide, percent, percent type)
self._nuclides = []
# The single instance of Macroscopic data present in this material
# (only one is allowed, hence this is different than _nuclides, etc)
self._macroscopic = None
# If specified, a list of table names
self._sab = []
# Set density if provided
if density is not None:
self.set_density(density_units, density)
# Add components if provided
if components is not None:
self.add_components(components, percent_type=percent_type)
def __repr__(self) -> str:
string = 'Material\n'
string += '{: <16}=\t{}\n'.format('\tID', self._id)
string += '{: <16}=\t{}\n'.format('\tName', self._name)
string += '{: <16}=\t{}\n'.format('\tTemperature', self._temperature)
string += '{: <16}=\t{}'.format('\tDensity', self._density)
string += f' [{self._density_units}]\n'
string += '{: <16}=\t{} [cm^3]\n'.format('\tVolume', self._volume)
string += '{: <16}=\t{}\n'.format('\tDepletable', self._depletable)
string += '{: <16}\n'.format('\tS(a,b) Tables')
if self._ncrystal_cfg:
string += '{: <16}=\t{}\n'.format('\tNCrystal conf', self._ncrystal_cfg)
for sab in self._sab:
string += '{: <16}=\t{}\n'.format('\tS(a,b)', sab)
string += '{: <16}\n'.format('\tNuclides')
for nuclide, percent, percent_type in self._nuclides:
string += '{: <16}'.format('\t{}'.format(nuclide))
string += f'=\t{percent: <12} [{percent_type}]\n'
if self._macroscopic is not None:
string += '{: <16}\n'.format('\tMacroscopic Data')
string += '{: <16}'.format('\t{}'.format(self._macroscopic))
return string
@property
def name(self) -> str | None:
return self._name
@name.setter
def name(self, name: str | None):
if name is not None:
cv.check_type(f'name for Material ID="{self._id}"',
name, str)
self._name = name
else:
self._name = ''
@property
def temperature(self) -> float | None:
return self._temperature
@temperature.setter
def temperature(self, temperature: Real | None):
cv.check_type(f'Temperature for Material ID="{self._id}"',
temperature, (Real, type(None)))
self._temperature = temperature
@property
def density(self) -> float | None:
return self._density
@property
def density_units(self) -> str:
return self._density_units
@property
def depletable(self) -> bool:
return self._depletable
@depletable.setter
def depletable(self, depletable: bool):
cv.check_type(f'Depletable flag for Material ID="{self._id}"',
depletable, bool)
self._depletable = depletable
@property
def paths(self) -> list[str]:
if self._paths is None:
raise ValueError('Material instance paths have not been determined. '
'Call the Geometry.determine_paths() method.')
return self._paths
@property
def num_instances(self) -> int:
if self._num_instances is None:
raise ValueError(
'Number of material instances have not been determined. Call '
'the Geometry.determine_paths() method.')
return self._num_instances
@property
def nuclides(self) -> list[namedtuple]:
return self._nuclides
@property
def isotropic(self) -> list[str]:
return self._isotropic
@isotropic.setter
def isotropic(self, isotropic: Iterable[str]):
cv.check_iterable_type('Isotropic scattering nuclides', isotropic,
str)
self._isotropic = list(isotropic)
@property
def average_molar_mass(self) -> float:
# Using the sum of specified atomic or weight amounts as a basis, sum
# the mass and moles of the material
mass = 0.
moles = 0.
for nuc in self.nuclides:
if nuc.percent_type == 'ao':
mass += nuc.percent * openmc.data.atomic_mass(nuc.name)
moles += nuc.percent
else:
moles += nuc.percent / openmc.data.atomic_mass(nuc.name)
mass += nuc.percent
# Compute and return the molar mass
if moles == 0.0:
raise ValueError("Material has no nuclides; cannot compute molar mass")
return mass / moles
@property
def volume(self) -> float | None:
return self._volume
@volume.setter
def volume(self, volume: Real):
if volume is not None:
cv.check_type('material volume', volume, Real)
self._volume = volume
@property
def ncrystal_cfg(self) -> str | None:
return self._ncrystal_cfg
@property
def fissionable_mass(self) -> float:
if self.volume is None:
raise ValueError("Volume must be set in order to determine mass.")
density = 0.0
for nuc, atoms_per_bcm in self.get_nuclide_atom_densities().items():
Z = openmc.data.zam(nuc)[0]
if Z >= 90:
density += 1e24 * atoms_per_bcm * openmc.data.atomic_mass(nuc) \
/ openmc.data.AVOGADRO
return density*self.volume
@property
def decay_photon_energy(self) -> Univariate | None:
warnings.warn(
"The 'decay_photon_energy' property has been replaced by the "
"get_decay_photon_energy() method and will be removed in a future "
"version.", FutureWarning)
return self.get_decay_photon_energy(0.0)
def get_decay_photon_energy(
self,
clip_tolerance: float = 1e-6,
units: str = 'Bq',
volume: float | None = None,
exclude_nuclides: list[str] | None = None,
include_nuclides: list[str] | None = None
) -> Univariate | None:
r"""Return energy distribution of decay photons from unstable nuclides.
.. versionadded:: 0.14.0
Parameters
----------
clip_tolerance : float
Maximum fraction of :math:`\sum_i x_i p_i` for discrete distributions
that will be discarded.
units : {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3', 'Bq/m3'}
Specifies the units on the integral of the distribution.
volume : float, optional
Volume of the material. If not passed, defaults to using the
:attr:`Material.volume` attribute.
exclude_nuclides : list of str, optional
Nuclides to exclude from the photon source calculation.
include_nuclides : list of str, optional
Nuclides to include in the photon source calculation. If specified,
only these nuclides are used.
Returns
-------
Univariate or None
Decay photon energy distribution. The integral of this distribution is
the total intensity of the photon source in the requested units.
"""
cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3', 'Bq/m3'})
if exclude_nuclides is not None and include_nuclides is not None:
raise ValueError("Cannot specify both exclude_nuclides and include_nuclides")
if units == 'Bq':
multiplier = volume if volume is not None else self.volume
if multiplier is None:
raise ValueError("volume must be specified if units='Bq'")
elif units == 'Bq/cm3':
multiplier = 1
elif units == 'Bq/m3':
multiplier = 1e6
elif units == 'Bq/g':
multiplier = 1.0 / self.get_mass_density()
elif units == 'Bq/kg':
multiplier = 1000.0 / self.get_mass_density()
dists = []
probs = []
for nuc, atoms_per_bcm in self.get_nuclide_atom_densities().items():
if exclude_nuclides is not None and nuc in exclude_nuclides:
continue
if include_nuclides is not None and nuc not in include_nuclides:
continue
source_per_atom = openmc.data.decay_photon_energy(nuc)
if source_per_atom is not None and atoms_per_bcm > 0.0:
dists.append(source_per_atom)
probs.append(1e24 * atoms_per_bcm * multiplier)
# If no photon sources, exit early
if not dists:
return None
# Get combined distribution, clip low-intensity values in discrete spectra
combined = openmc.data.combine_distributions(dists, probs)
if isinstance(combined, (Discrete, Mixture)):
combined.clip(clip_tolerance, inplace=True)
# If clipping resulted in a single distribution within a mixture, pick
# out that single distribution
if isinstance(combined, Mixture) and len(combined.distribution) == 1:
combined = combined.distribution[0]
return combined
def get_photon_contact_dose_rate(
self,
dose_quantity: str = "absorbed-air",
build_up: float = 2.0,
by_nuclide: bool = False
) -> float | dict[str, float]:
"""Compute the photon contact dose rate (CDR) produced by radioactive decay
of the material.
The contact dose rate is calculated from decay photon energy spectra for
each nuclide in the material, combined with photon mass attenuation data
for the material and the appropriate response function for the dose quantity.
A slab-geometry approximation and a photon build-up factor are used.
Absorbed-air dose:
The approach follows the FISPACT-II manual (UKAEA-CCFE-RE(21)02 - May 2021).
Appendix C.7.1.
This method integrates over the photon energy:
(B/2) * (mu_en_air(E) / mu_material(E)) * E * S(E)
Effective dose:
The approach uses ICRP-116 effective dose coefficients to convert the photon
fluence due to decay photons to effective dose.
This method integrates over the photon energy:
(B/2) * (h_e(E) / mu_material(E)) * S(E)
where:
- mu_en_air(E) is the air mass energy-absorption coefficient,
- mu_material(E) is the photon mass attenuation coefficient of the material,
- S(E) is the photon emission spectrum per atom,
- h_e(E) is the ICRP-116 effective dose coefficient,
- B is the build-up factor,
- E is the photon energy.
Parameters
----------
dose_quantity : {'absorbed-air', 'effective'}, optional
Specifies the dose quantity to be calculated.
The only supported options are 'absorbed-air' which implements the methodology
from FISPACT-II, and 'effective' which uses ICRP-116 effective dose coefficients.
build_up : float, optional. The default value is 2.0 as suggested in the FISPACT-II
manual.
by_nuclide : bool, optional
Specifies if the cdr should be returned for the material as a
whole or per nuclide. Default is False.
Limitations
----------
This method does not implement correction from Bremsstrahlung particles which can be
relevant at close distances.
In addition, it computes the gamma contact dose rate only for the unstable nuclides
for which the radiation source specification is present in the chain file.
Returns
-------
cdr : float or dict[str, float]
Contact Dose Rate due to decay photons.
'absorbed-air': returns the absorbed dose in air [Gy/hr].
'effective': returns the effective dose [Sv/hr].
"""
cv.check_type("by_nuclide", by_nuclide, bool)
cv.check_type("dose_quantity", dose_quantity, str)
cv.check_value("dose_quantity", dose_quantity, {'absorbed-air', 'effective'})
cv.check_type("build_up", build_up, Real)
cv.check_greater_than("build_up", build_up, 0.0)
nuc_densities = self.get_nuclide_atom_densities()
if not nuc_densities:
raise ValueError("Material has no nuclides; cannot compute mass attenuation")
# Collect partial mass densities ρ_i [g/cm³] and elemental mass
# attenuation coefficients µ_i/ρ_i [cm²/g] per nuclide
nuc_attenuation = []
for nuc, atom_density_bcm in nuc_densities.items():
Z = openmc.data.zam(nuc)[0]
mu_over_rho = openmc.data.mass_attenuation_coefficient(Z)
rho_i = (
atom_density_bcm * 1.0e24
* openmc.data.atomic_mass(nuc) / openmc.data.AVOGADRO
)
nuc_attenuation.append((rho_i, mu_over_rho))
# Build union energy grid across all nuclides
mu_e_vals = reduce(np.union1d, [t.x for _, t in nuc_attenuation])
# Build the material linear attenuation coefficient µ_material(E) [cm⁻¹]
# as the sum of ρ_i * (µ_i/ρ_i)(E) over all nuclides
mu_material_vals = np.zeros(len(mu_e_vals))
for rho_i, mu_over_rho in nuc_attenuation:
mu_material_vals += rho_i * mu_over_rho(mu_e_vals)
mu_material = Tabulated1D(
mu_e_vals, mu_material_vals, breakpoints=[len(mu_e_vals)], interpolation=[5])
# CDR computation
cdr = {}
geometry_factor_slab = 0.5
# ancillary conversion factors for clarity
seconds_per_hour = 3600.0
grams_per_kg = 1000.0
sv_per_psv = 1e-12
if dose_quantity == 'absorbed-air':
# mu_en/rho for air [cm²/g] as a function of energy [eV]
response_f = mass_energy_absorption_coefficient("air", data_source="nist126")
# Factor to convert [eV cm²/(b g s)] to [Gy/h]
multiplier = (build_up * geometry_factor_slab * seconds_per_hour
* grams_per_kg * 1e24 * JOULE_PER_EV)
elif dose_quantity == 'effective':
# effective dose as a function of photon fluence [pSv cm²]
response_f_x, response_f_y = dose_coefficients(
"photon", geometry='AP', data_source='icrp116')
response_f = Tabulated1D(response_f_x, response_f_y, breakpoints=[
len(response_f_x)], interpolation=[5])
# Convert [pSv cm²/(b-s)] to [Sv/h]
multiplier = (build_up * geometry_factor_slab * seconds_per_hour
* sv_per_psv * 1e24)
for nuc, nuc_atoms_per_bcm in self.get_nuclide_atom_densities().items():
photon_source_per_atom = openmc.data.decay_photon_energy(nuc)
# nuclides with no contribution
if photon_source_per_atom is None or nuc_atoms_per_bcm <= 0.0:
cdr[nuc] = 0.0
continue
if not isinstance(photon_source_per_atom, (Discrete, Tabular)):
raise ValueError(
f"Unknown decay photon energy data type for nuclide {nuc}"
f"value returned: {type(photon_source_per_atom)}"
)
e_vals = photon_source_per_atom.x
p_vals = photon_source_per_atom.p
# Construct list of energies from (photon source, response function,
# mu_en_air) for clipping to common energy range
e_lists = [e_vals, response_f.x, mu_e_vals]
# clip distributions for values outside the tabulated values
left_bound = max(a.min() for a in e_lists)
right_bound = min(a.max() for a in e_lists)
mask = (e_vals >= left_bound) & (e_vals <= right_bound)
e_vals = e_vals[mask]
p_vals = p_vals[mask]
if isinstance(photon_source_per_atom, Tabular):
# limit the computation to the tabulated mu_en_air range
e_union = reduce(np.union1d, e_lists)
e_union = e_union[(e_union >= left_bound) & (e_union <= right_bound)]
if len(e_union) < 2:
raise ValueError("Not enough overlapping energy points to compute CDR")
# Histogram interpolation: each new point inherits the value of
# the nearest original point to its left
p_vals = p_vals[np.searchsorted(e_vals, e_union, side='right') - 1]
e_vals = e_union
mu_vals = mu_material(e_vals)
if dose_quantity == 'absorbed-air':
# Compute (µ_en_air(E) / µ_material(E)) * E * S(E)
integrand = (response_f(e_vals) / mu_vals) * p_vals * e_vals
elif dose_quantity == 'effective':
# Compute (h_e(E) / µ_material(E)) * S(E)
integrand = (response_f(e_vals) / mu_vals) * p_vals
if isinstance(photon_source_per_atom, Discrete):
cdr_nuc = np.sum(integrand)
elif isinstance(photon_source_per_atom, Tabular):
cdr_nuc = np.trapezoid(integrand, e_vals)
# Compute air-absorbed dose [Gy/h] or effective dose [Sv/h]
cdr[nuc] = float(cdr_nuc * nuc_atoms_per_bcm * multiplier)
return cdr if by_nuclide else sum(cdr.values())
@classmethod
def from_hdf5(cls, group: h5py.Group) -> Material:
"""Create material from HDF5 group
Parameters
----------
group : h5py.Group
Group in HDF5 file
Returns
-------
openmc.Material
Material instance
"""
mat_id = int(group.name.split('/')[-1].lstrip('material '))
name = group['name'][()].decode() if 'name' in group else ''
density = group['atom_density'][()]
if 'nuclide_densities' in group:
nuc_densities = group['nuclide_densities'][()]
# Create the Material
material = cls(mat_id, name)
material.depletable = bool(group.attrs['depletable'])
if 'volume' in group.attrs:
material.volume = group.attrs['volume']
if "temperature" in group.attrs:
material.temperature = group.attrs["temperature"]
# Read the names of the S(a,b) tables for this Material and add them
if 'sab_names' in group:
sab_tables = group['sab_names'][()]
for sab_table in sab_tables:
name = sab_table.decode()
material.add_s_alpha_beta(name)
# Set the Material's density to atom/b-cm as used by OpenMC
material.set_density(density=density, units='atom/b-cm')
if 'nuclides' in group:
nuclides = group['nuclides'][()]
# Add all nuclides to the Material
for fullname, density in zip(nuclides, nuc_densities):
name = fullname.decode().strip()
material.add_nuclide(name, percent=density, percent_type='ao')
if 'macroscopics' in group:
macroscopics = group['macroscopics'][()]
# Add all macroscopics to the Material
for fullname in macroscopics:
name = fullname.decode().strip()
material.add_macroscopic(name)
return material
@classmethod
def from_ncrystal(cls, cfg, **kwargs) -> Material:
"""Create material from NCrystal configuration string.
Density, temperature, and material composition, and (ultimately) thermal
neutron scattering will be automatically be provided by NCrystal based
on this string. The name and material_id parameters are simply passed on
to the Material constructor.
.. versionadded:: 0.13.3
Parameters
----------
cfg : str
NCrystal configuration string
**kwargs
Keyword arguments passed to :class:`openmc.Material`
Returns
-------
openmc.Material
Material instance
"""
try:
import NCrystal
except ModuleNotFoundError as e:
raise RuntimeError('The .from_ncrystal method requires'
' NCrystal to be installed.') from e
nc_mat = NCrystal.createInfo(cfg)
def openmc_natabund(Z):
#nc_mat.getFlattenedComposition might need natural abundancies.
#This call-back function is used so NCrystal can flatten composition
#using OpenMC's natural abundancies. In practice this function will
#only get invoked in the unlikely case where a material is specified
#by referring both to natural elements and specific isotopes of the
#same element.
elem_name = openmc.data.ATOMIC_SYMBOL[Z]
return [
(int(iso_name[len(elem_name):]), abund)
for iso_name, abund in openmc.data.isotopes(elem_name)
]
flat_compos = nc_mat.getFlattenedComposition(
preferNaturalElements=True, naturalAbundProvider=openmc_natabund)
# Create the Material
material = cls(temperature=nc_mat.getTemperature(), **kwargs)
for Z, A_vals in flat_compos:
elemname = openmc.data.ATOMIC_SYMBOL[Z]
for A, frac in A_vals:
if A:
material.add_nuclide(f'{elemname}{A}', frac)
else:
material.add_element(elemname, frac)
material.set_density('g/cm3', nc_mat.getDensity())
material._ncrystal_cfg = NCrystal.normaliseCfg(cfg)
return material
def add_volume_information(self, volume_calc):
"""Add volume information to a material.
Parameters
----------
volume_calc : openmc.VolumeCalculation
Results from a stochastic volume calculation
"""
if volume_calc.domain_type == 'material':
if self.id in volume_calc.volumes:
self._volume = volume_calc.volumes[self.id].n
self._atoms = volume_calc.atoms[self.id]
else:
raise ValueError('No volume information found for material ID={}.'
.format(self.id))
else:
raise ValueError(f'No volume information found for material ID={self.id}.')
def set_density(self, units: str, density: float | None = None):
"""Set the density of the material
Parameters
----------
units : {'g/cm3', 'g/cc', 'kg/m3', 'atom/b-cm', 'atom/cm3', 'sum', 'macro'}
Physical units of density.
density : float, optional
Value of the density. Must be specified unless units is given as
'sum'.
"""
cv.check_value('density units', units, DENSITY_UNITS)
self._density_units = units
if units == 'sum':
if density is not None:
msg = 'Density "{}" for Material ID="{}" is ignored ' \
'because the unit is "sum"'.format(density, self.id)
warnings.warn(msg)
else:
if density is None:
msg = 'Unable to set the density for Material ID="{}" ' \
'because a density value must be given when not using ' \
'"sum" unit'.format(self.id)
raise ValueError(msg)
cv.check_type(f'the density for Material ID="{self.id}"',
density, Real)
self._density = density
def add_nuclide(self, nuclide: str, percent: float, percent_type: str = 'ao'):
"""Add a nuclide to the material
Parameters
----------
nuclide : str
Nuclide to add, e.g., 'Mo95'
percent : float
Atom or weight percent
percent_type : {'ao', 'wo'}
'ao' for atom percent and 'wo' for weight percent
"""
cv.check_type('nuclide', nuclide, str)
cv.check_type('percent', percent, Real)
cv.check_value('percent type', percent_type, {'ao', 'wo'})
cv.check_greater_than('percent', percent, 0, equality=True)
if self._macroscopic is not None:
msg = 'Unable to add a Nuclide to Material ID="{}" as a ' \
'macroscopic data-set has already been added'.format(self._id)
raise ValueError(msg)
if self._ncrystal_cfg is not None:
raise ValueError("Cannot add nuclides to NCrystal material")
# If nuclide name doesn't look valid, give a warning
try:
Z, _, _ = openmc.data.zam(nuclide)
except ValueError as e:
warnings.warn(str(e))
else:
# For actinides, have the material be depletable by default
if Z >= 89:
self.depletable = True
self._nuclides.append(NuclideTuple(nuclide, percent, percent_type))
def add_components(self, components: dict, percent_type: str = 'ao'):
""" Add multiple elements or nuclides to a material
.. versionadded:: 0.13.1
Parameters
----------
components : dict of str to float or dict
Dictionary mapping element or nuclide names to their atom or weight
percent. To specify enrichment of an element, the entry of
``components`` for that element must instead be a dictionary
containing the keyword arguments as well as a value for
``'percent'``
percent_type : {'ao', 'wo'}
'ao' for atom percent and 'wo' for weight percent
Examples
--------
>>> mat = openmc.Material()
>>> components = {'Li': {'percent': 1.0,
>>> 'enrichment': 60.0,
>>> 'enrichment_target': 'Li7'},
>>> 'Fl': 1.0,
>>> 'Be6': 0.5}
>>> mat.add_components(components)
"""
for component, params in components.items():
cv.check_type('component', component, str)
if isinstance(params, Real):
params = {'percent': params}
else:
cv.check_type('params', params, dict)
if 'percent' not in params:
raise ValueError("An entry in the dictionary does not have "
"a required key: 'percent'")
params['percent_type'] = percent_type
# check if nuclide
if not component.isalpha():
self.add_nuclide(component, **params)
else:
self.add_element(component, **params)
def remove_nuclide(self, nuclide: str):
"""Remove a nuclide from the material
Parameters
----------
nuclide : str
Nuclide to remove
"""
cv.check_type('nuclide', nuclide, str)
# If the Material contains the Nuclide, delete it
for nuc in reversed(self.nuclides):
if nuclide == nuc.name:
self.nuclides.remove(nuc)
def remove_element(self, element):
"""Remove an element from the material
.. versionadded:: 0.13.1
Parameters
----------
element : str
Element to remove
"""
cv.check_type('element', element, str)
# If the Material contains the element, delete it
for nuc in reversed(self.nuclides):
element_name = re.split(r'\d+', nuc.name)[0]
if element_name == element:
self.nuclides.remove(nuc)
def add_macroscopic(self, macroscopic: str):
"""Add a macroscopic to the material. This will also set the
density of the material to 1.0, unless it has been otherwise set,
as a default for Macroscopic cross sections.
Parameters
----------
macroscopic : str
Macroscopic to add
"""
# Ensure no nuclides, elements, or sab are added since these would be
# incompatible with macroscopics
if self._nuclides or self._sab:
msg = 'Unable to add a Macroscopic data set to Material ID="{}" ' \
'with a macroscopic value "{}" as an incompatible data ' \
'member (i.e., nuclide or S(a,b) table) ' \
'has already been added'.format(self._id, macroscopic)
raise ValueError(msg)
if not isinstance(macroscopic, str):
msg = 'Unable to add a Macroscopic to Material ID="{}" with a ' \
'non-string value "{}"'.format(self._id, macroscopic)
raise ValueError(msg)
if self._macroscopic is None:
self._macroscopic = macroscopic
else:
msg = 'Unable to add a Macroscopic to Material ID="{}". ' \
'Only one Macroscopic allowed per ' \
'Material.'.format(self._id)
raise ValueError(msg)
# Generally speaking, the density for a macroscopic object will
# be 1.0. Therefore, lets set density to 1.0 so that the user
# doesn't need to set it unless its needed.
# Of course, if the user has already set a value of density,
# then we will not override it.
if self._density is None:
self.set_density('macro', 1.0)
def remove_macroscopic(self, macroscopic: str):
"""Remove a macroscopic from the material
Parameters
----------
macroscopic : str
Macroscopic to remove
"""
if not isinstance(macroscopic, str):
msg = 'Unable to remove a Macroscopic "{}" in Material ID="{}" ' \
'since it is not a string'.format(self._id, macroscopic)
raise ValueError(msg)
# If the Material contains the Macroscopic, delete it
if macroscopic == self._macroscopic:
self._macroscopic = None
def add_element(self, element: str, percent: float, percent_type: str = 'ao',
enrichment: float | None = None,
enrichment_target: str | None = None,
enrichment_type: str | None = None,
cross_sections: str | None = None):
"""Add a natural element to the material
Parameters
----------
element : str
Element to add, e.g., 'Zr' or 'Zirconium'
percent : float
Atom or weight percent
percent_type : {'ao', 'wo'}, optional
'ao' for atom percent and 'wo' for weight percent. Defaults to atom
percent.
enrichment : float, optional
Enrichment of an enrichment_target nuclide in percent (ao or wo).
If enrichment_target is not supplied then it is enrichment for U235
in weight percent. For example, input 4.95 for 4.95 weight percent
enriched U.
Default is None (natural composition).
enrichment_target: str, optional
Single nuclide name to enrich from a natural composition (e.g., 'O16')
.. versionadded:: 0.12
enrichment_type: {'ao', 'wo'}, optional
'ao' for enrichment as atom percent and 'wo' for weight percent.
Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment
.. versionadded:: 0.12
cross_sections : str, optional
Location of cross_sections.xml file.
Notes
-----
General enrichment procedure is allowed only for elements composed of
two isotopes. If `enrichment_target` is given without `enrichment`
natural composition is added to the material.
"""
cv.check_type('nuclide', element, str)
cv.check_type('percent', percent, Real)