diff --git a/openmc/data/__init__.py b/openmc/data/__init__.py index c2d35565a8a..ddd60ae22d7 100644 --- a/openmc/data/__init__.py +++ b/openmc/data/__init__.py @@ -35,3 +35,4 @@ from .function import * from .effective_dose.dose import dose_coefficients +from .mass_energy_absorption import mu_en_coefficients diff --git a/openmc/data/data.py b/openmc/data/data.py index 5ecadd37be0..757540f983c 100644 --- a/openmc/data/data.py +++ b/openmc/data/data.py @@ -274,6 +274,7 @@ # Unit conversions EV_PER_MEV = 1.0e6 JOULE_PER_EV = 1.602176634e-19 +BARN_PER_CM_SQ = 1.0e24 # Avogadro's constant AVOGADRO = 6.02214076e23 diff --git a/openmc/data/mass_energy_absorption.py b/openmc/data/mass_energy_absorption.py new file mode 100644 index 00000000000..e0a2e5692a6 --- /dev/null +++ b/openmc/data/mass_energy_absorption.py @@ -0,0 +1,93 @@ +import numpy as np + +import openmc.checkvalue as cv +from openmc.data import EV_PER_MEV + +# Embedded NIST-126 data +# Air (Dry Near Sea Level) — NIST Standard Reference Database 126 Table 4 (doi: 10.18434/T4D01F) +# Columns: Energy (MeV), μen/ρ (cm^2/g) +_NIST126_AIR = np.array( + [ + [1.00000e-03, 3.599e03], + [1.50000e-03, 1.188e03], + [2.00000e-03, 5.262e02], + [3.00000e-03, 1.614e02], + [3.20290e-03, 1.330e02], + [3.20290e-03, 1.460e02], + [4.00000e-03, 7.636e01], + [5.00000e-03, 3.931e01], + [6.00000e-03, 2.270e01], + [8.00000e-03, 9.446e00], + [1.00000e-02, 4.742e00], + [1.50000e-02, 1.334e00], + [2.00000e-02, 5.389e-01], + [3.00000e-02, 1.537e-01], + [4.00000e-02, 6.833e-02], + [5.00000e-02, 4.098e-02], + [6.00000e-02, 3.041e-02], + [8.00000e-02, 2.407e-02], + [1.00000e-01, 2.325e-02], + [1.50000e-01, 2.496e-02], + [2.00000e-01, 2.672e-02], + [3.00000e-01, 2.872e-02], + [4.00000e-01, 2.949e-02], + [5.00000e-01, 2.966e-02], + [6.00000e-01, 2.953e-02], + [8.00000e-01, 2.882e-02], + [1.00000e00, 2.789e-02], + [1.25000e00, 2.666e-02], + [1.50000e00, 2.547e-02], + [2.00000e00, 2.345e-02], + [3.00000e00, 2.057e-02], + [4.00000e00, 1.870e-02], + [5.00000e00, 1.740e-02], + [6.00000e00, 1.647e-02], + [8.00000e00, 1.525e-02], + [1.00000e01, 1.450e-02], + [1.50000e01, 1.353e-02], + [2.00000e01, 1.311e-02], + ], + dtype=float, +) + +# Registry of embedded tables: (data_source, material) -> ndarray +# Table shape: (N, 2) with columns [Energy (MeV), μen/ρ (cm^2/g)] +_MUEN_TABLES = { + ("nist126", "air"): _NIST126_AIR, +} + + +def mu_en_coefficients( + material: str, data_source: str = "nist126" +) -> tuple[np.ndarray, np.ndarray]: + """Return tabulated mass energy-absorption coefficients. + + Parameters + ---------- + material : {'air'} + Material compound for which to load coefficients. + data_source : {'nist126'} + Source library. + + Returns + ------- + energy : numpy.ndarray + Energies [eV] + mu_en_coeffs : numpy.ndarray + Mass energy-absorption coefficients [cm^2/g] + """ + cv.check_value("material", material, {"air"}) + cv.check_value("data_source", data_source, {"nist126"}) + + key = (data_source, material) + if key not in _MUEN_TABLES: + available = sorted({m for (ds, m) in _MUEN_TABLES.keys() if ds == data_source}) + raise ValueError( + f"'{material}' has no embedded μen/ρ table for data source {data_source}. " + f"Available materials for {data_source}: {available}" + ) + + data = _MUEN_TABLES[key] + energy = data[:, 0].copy() * EV_PER_MEV # MeV -> eV + mu_en_coeffs = data[:, 1].copy() + return energy, mu_en_coeffs diff --git a/openmc/data/photon_attenuation.py b/openmc/data/photon_attenuation.py new file mode 100644 index 00000000000..adaab33e4de --- /dev/null +++ b/openmc/data/photon_attenuation.py @@ -0,0 +1,78 @@ +from openmc.exceptions import DataError + +from .data import ATOMIC_SYMBOL, ELEMENT_SYMBOL, zam +from .function import Sum +from .library import DataLibrary +from .photon import IncidentPhoton + + +_PHOTON_LIB: DataLibrary | None = None +_PHOTON_DATA: dict[str, IncidentPhoton] = {} + + +def _get_photon_data(nuclide: str) -> IncidentPhoton | None: + global _PHOTON_LIB + + if _PHOTON_LIB is None: + try: + _PHOTON_LIB = DataLibrary.from_xml() + except Exception as err: + raise DataError( + "A cross section library must be specified with " + "openmc.config['cross_sections'] in order to load photon data." + ) from err + + lib = _PHOTON_LIB.get_by_material(nuclide, data_type="photon") + if lib is None: + return None + + if nuclide not in _PHOTON_DATA: + _PHOTON_DATA[nuclide] = IncidentPhoton.from_hdf5(lib["path"]) + + return _PHOTON_DATA[nuclide] + + +def linear_attenuation_xs(element_input: str) -> Sum | None: + """Return total photon interaction cross section for a nuclide. + + Parameters + ---------- + element_input : str + Name of nuclide or element + + Returns + ------- + openmc.data.Sum or None + Sum of the relevant photon reaction cross sections as a function of + photon energy, or None if no photon data exist for *nuclide*. + """ + + try: + z = zam(element_input)[0] + element = ATOMIC_SYMBOL[z] + except (ValueError, KeyError, TypeError): + if element_input not in ELEMENT_SYMBOL.values(): + raise ValueError(f"Element '{element_input}' not found in ELEMENT_SYMBOL.") + element = element_input + + photon_data = _get_photon_data(element) + if photon_data is None: + return None + + photon_mts = (502, 504, 515, 517, 522) + + xs_list = [] + for reaction in photon_data.reactions.values(): + mt = getattr(reaction, "mt", None) + if mt not in photon_mts: + continue + + xs_list.append(reaction.xs) + + if not xs_list: + return None + + return Sum(xs_list) + + + diff --git a/openmc/material.py b/openmc/material.py index 735a0574326..7be17d9fc41 100644 --- a/openmc/material.py +++ b/openmc/material.py @@ -2,6 +2,7 @@ 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 @@ -22,8 +23,11 @@ from .utility_funcs import input_path from . import waste from openmc.checkvalue import PathLike -from openmc.stats import Univariate, Discrete, Mixture -from openmc.data.data import _get_element_symbol +from openmc.stats import Univariate, Discrete, Mixture, Tabular +from openmc.data.data import _get_element_symbol, BARN_PER_CM_SQ, JOULE_PER_EV +from openmc.data.function import Combination, Tabulated1D, Sum +from openmc.data import mu_en_coefficients +from openmc.data.photon_attenuation import linear_attenuation_xs # Units for density supported by OpenMC @@ -409,6 +413,246 @@ def get_decay_photon_energy( return combined + def get_photon_mass_attenuation(self) -> Sum | None: + """Return the photon mass attenuation distribution μ/ρ(E) [cm^2/g]. + + the linear attenuation coefficient of the material is given by: + μ(E) = Σ_el N_el * σ_el(E) + with N_el in [atom/b-cm] and σ_el(E) in [barn/atom] => μ in [1/cm]. + + The mass attenuation coefficients are given by: + μ/ρ(E) = μ(E) / ρ + => [1/cm] / [g/cm^3] = [cm^2/g] + + Parameters + ---------- + self : openmc.Material + + Returns + ------- + openmc.data.Sum or None + Sum of Tabulated1D terms giving μ/ρ(E) in [cm^2/g], or None if no photon + data exist for any constituents. + """ + el_dens = self.get_element_atom_densities() + if not el_dens: + raise ValueError( + f'For Material ID="{self.id}" no element densities are defined.' + ) + + # Mass density of the material [g/cm^3] + rho = self.get_mass_density() # g/cm^3 + + if rho is None or rho <= 0.0: + raise ValueError( + f'Material ID="{self.id}" has non-positive mass density; ' + "cannot compute mass attenuation coefficient." + ) + + + inv_rho = 1.0 / rho + terms = [] + + for el, n_el in el_dens.items(): + xs_sum = linear_attenuation_xs(el) # barns/atom functions vs E + if xs_sum is None or n_el == 0.0: + continue + + scale = float(n_el) * inv_rho # (atom/b-cm) / (g/cm^3) = (atom*cm^2)/(barn*g) + + for f in xs_sum.functions: + if not isinstance(f, Tabulated1D): + raise TypeError( + f"Expected Tabulated1D photon XS for element {el}, got {type(f)!r}." + ) + # keep x, breakpoints, interpolation; scale y. + terms.append( + Tabulated1D( + f.x, + np.asarray(f.y, dtype=float) * scale, + breakpoints=f.breakpoints, + interpolation=f.interpolation, + ) + ) + + return Sum(terms) if terms else None + + + def get_photon_contact_dose_rate(self, by_nuclide: bool = False) -> float | dict[str, float]: + """Compute the photon contact dose rate (CDR) produced by radioactive decay + of the material. + + A slab-geometry approximation and a fixed photon build-up factor are used. + + The method implemented here follows the approach described in FISPACT-II + manual (UKAEA-CCFE-RE(21)02 - May 2021). Appendix C.7.1. + + 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 mass energy-absorption coefficients for air. + + + The calculation integrates, over photon energy, the quantity:: + + (mu_en_air(E) / mu_material(E)) * 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, + - E is the photon energy. + + Results are converted to dose rate units using physical constants and + material mass density. + + + Parameters + ---------- + by_nuclide : bool, optional + Specifies if the cdr should be returned for the material as a + whole or per nuclide. Default is False. + + Returns + ------- + cdr : float or dict[str, float] + Photon Contact Dose Rate due to material decay in [Sv/hr]. + """ + + cv.check_type("by_nuclide", by_nuclide, bool) + + # Mass density of the material [g/cm^3] + rho = self.get_mass_density() # g/cm^3 + + if rho is None or rho <= 0.0: + raise ValueError( + f'Material ID="{self.id}" has non-positive mass density; ' + "cannot compute mass attenuation coefficient." + ) + + # mu_en/ rho for air distribution, [eV, cm2/g] + mu_en_x, mu_en_y = mu_en_coefficients("air", data_source="nist126") + mu_en_air = Tabulated1D(mu_en_x, mu_en_y, breakpoints=None, interpolation=[5]) + + mu_en_x_low = mu_en_air.x[0] + mu_en_x_high = mu_en_air.x[-1] + + # photon mass attenuation distribution as a function of energy + # distribution values in [cm2/g] + mass_attenuation_dist = self.get_photon_mass_attenuation() + if mass_attenuation_dist is None: + raise ValueError("Cannot compute photon mass attenuation for material") + + # CDR computation + cdr = {} + + # build up factor - as reported from fispact reference + B = 2.0 + geometry_factor_slab = 0.5 + + # ancillary conversion factors for clarity + seconds_per_hour = 3600.0 + grams_per_kg = 1000.0 + + # converts [eV barns-1 cm-1 s-1] to [Sv hr-1] + multiplier = ( + B + * geometry_factor_slab + * seconds_per_hour + * grams_per_kg + * (1 / rho) + * BARN_PER_CM_SQ + * JOULE_PER_EV + ) + + for nuc, nuc_atoms_per_bcm in self.get_nuclide_atom_densities().items(): + + cdr_nuc = 0.0 + + 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 isinstance(photon_source_per_atom, (Discrete, Tabular)): + e_vals = np.array(photon_source_per_atom.x) + p_vals = np.array(photon_source_per_atom.p) + + # clip distributions for values outside the air tabulated values + mask = (e_vals >= mu_en_x_low) & (e_vals <= mu_en_x_high) + e_vals = e_vals[mask] + p_vals = p_vals[mask] + + else: + raise ValueError( + f"Unknown decay photon energy data type for nuclide {nuc}" + f"value returned: {type(photon_source_per_atom)}" + ) + + if isinstance(photon_source_per_atom, Discrete): + mu_vals = np.array(mass_attenuation_dist(e_vals)) + if np.any(mu_vals <= 0.0): + zero_vals = e_vals[mu_vals <= 0.0] + raise ValueError( + f"Mass attenuation coefficient <= 0 at energies: {zero_vals}" + ) + # units [eV atoms-1 s-1] + cdr_nuc += np.sum((mu_en_air(e_vals) / mu_vals) * p_vals * e_vals) + + elif isinstance(photon_source_per_atom, Tabular): + + + # generate the tabulated1D function p x e + e_p_vals = np.array(e_vals*p_vals, dtype=float) + e_p_dist = Tabulated1D( + e_vals, e_p_vals, breakpoints=None, interpolation=[2] + ) + + # generate a union of abscissae + e_lists = [e_vals, mu_en_air.x] + for photon_xs in mass_attenuation_dist.functions: + e_lists.append(photon_xs.x) + e_union = reduce(np.union1d, e_lists) + + # limit the computation to the tabulated mu_en_air range + mask = (e_union >= mu_en_x_low) & (e_union <= mu_en_x_high) + e_union = e_union[mask] + if len(e_union) < 2: + raise ValueError("Not enough overlapping energy points to compute CDR") + + # check for negative denominator valuenters + mu_vals_check = np.array(mass_attenuation_dist(e_union)) + if np.any(mu_vals_check <= 0.0): + zero_vals = e_union[mu_vals_check <= 0.0] + raise ValueError( + f"Mass attenuation coefficient <= 0 at energies: {zero_vals}" + ) + + integrand_operator = Combination( + functions=[mu_en_air, e_p_dist, mass_attenuation_dist], + operations=[np.multiply, np.divide], + ) + + y_evaluated = integrand_operator(e_union) + + integrand_function = Tabulated1D( + e_union, y_evaluated, breakpoints=None, interpolation=[5] + ) + + cdr_nuc += integrand_function.integral()[-1] + + + # units [eV barns-1 cm-1 s-1] + cdr_nuc *= nuc_atoms_per_bcm + + # units [Sv hr-1] - includes build up factor + cdr_nuc *= multiplier + + cdr[nuc] = cdr_nuc + + return cdr if by_nuclide else sum(cdr.values()) + @classmethod def from_hdf5(cls, group: h5py.Group) -> Material: """Create material from HDF5 group diff --git a/tests/unit_tests/test_data_mu_en_coefficients.py b/tests/unit_tests/test_data_mu_en_coefficients.py new file mode 100644 index 00000000000..cf4173f3265 --- /dev/null +++ b/tests/unit_tests/test_data_mu_en_coefficients.py @@ -0,0 +1,18 @@ +from pytest import approx, raises + +from openmc.data import mu_en_coefficients + + +def test_mu_en_coefficients(): + # Spot checks on values from NIST tables + energy, mu_en = mu_en_coefficients("air") + assert energy[0] == approx(1e3) + assert mu_en[0] == approx(3.599e3) + assert energy[-1] == approx(2e7) + assert mu_en[-1] == approx(1.311e-2) + + # Invalid particle/geometry should raise an exception + with raises(ValueError): + mu_en_coefficients("pasta") + with raises(ValueError): + mu_en_coefficients("air", data_source="nist000") diff --git a/tests/unit_tests/test_data_photon_attenuation.py b/tests/unit_tests/test_data_photon_attenuation.py new file mode 100644 index 00000000000..ddaa5b2e942 --- /dev/null +++ b/tests/unit_tests/test_data_photon_attenuation.py @@ -0,0 +1,258 @@ +import os + +import numpy as np +import pytest + +import openmc +import openmc.data +import openmc.data.photon_attenuation as photon_attenuation +from openmc.data import IncidentPhoton +from openmc.data.function import Sum +from openmc.data.library import DataLibrary +from openmc.data.photon_attenuation import linear_attenuation_xs +from openmc.exceptions import DataError + +PHOTON_MTS = (502, 504, 515, 517, 522) + + +@pytest.fixture(scope="module") +def xs_filename(): + xs = os.environ.get("OPENMC_CROSS_SECTIONS") + if xs is None: + pytest.skip("OPENMC_CROSS_SECTIONS not set.") + return xs + + +@pytest.fixture(scope="module") +def elements_photon_xs(xs_filename): + """Dictionary of IncidentPhoton data indexed by atomic symbol.""" + lib = DataLibrary.from_xml(xs_filename) + + elements = ["H", "O", "Al", "C", "Ag", "U", "Pb", "V"] + data = {} + for symbol in elements: + entry = lib.get_by_material(symbol, data_type="photon") + if entry is None: + continue + data[symbol] = IncidentPhoton.from_hdf5(entry["path"]) + return data + + +@pytest.mark.parametrize("symbol", ["C", "Pb"]) +def test_linear_attenuation_xs_matches_sum(elements_photon_xs, symbol, monkeypatch): + """linear_attenuation_xs should reproduce the sum of the relevant + reaction channels from IncidentPhoton.reactions. + """ + element = elements_photon_xs.get(symbol) + if element is None: + pytest.skip(f"No photon data for {symbol} in cross section library.") + + assert isinstance(element, openmc.data.IncidentPhoton) + + # Use preloaded IncidentPhoton instead of reading via DataLibrary in the helper + monkeypatch.setattr(photon_attenuation, "_get_photon_data", lambda _: element) + + xs_sum = linear_attenuation_xs(symbol) + + # If the element has no relevant reactions, helper should return None + has_relevant = any(mt in element.reactions for mt in PHOTON_MTS) + if not has_relevant: + assert xs_sum is None + return + + assert isinstance(xs_sum, Sum) + + # Compare against explicit sum of reaction cross sections + energy = np.logspace(2, 4, 50) + expected = np.zeros_like(energy) + for mt in PHOTON_MTS: + if mt in element.reactions: + expected += element.reactions[mt].xs(energy) + + actual = xs_sum(energy) + assert np.allclose(actual, expected) + +def test_linear_attenuation_xs_element_conversion(elements_photon_xs, monkeypatch): + """linear_attenuation_xs should fetch the corresponding element data when + given a nuclide symbol. + """ + symbol_el = 'C' + symbol_nuc = 'C12' + element = elements_photon_xs.get(symbol_el) + if element is None: + pytest.skip(f"No photon data for {element} in cross section library.") + + # Use preloaded IncidentPhoton instead of reading via DataLibrary in the helper + monkeypatch.setattr(photon_attenuation, "_get_photon_data", lambda _: element) + + xs_el = linear_attenuation_xs(symbol_el) + xs_nuc = linear_attenuation_xs(symbol_nuc) + + if xs_el is None or xs_nuc is None: + pytest.skip("No relevant photon reactions for C or C12.") + + energy = np.logspace(2, 4, 50) + + element_values = xs_el(energy) + nuclide_values = xs_nuc(energy) + + assert np.array_equal(element_values, nuclide_values) + + + +def test_linear_attenuation_xs_returns_none_when_no_photon_data(monkeypatch): + """If _get_photon_data returns None, the helper should return None.""" + monkeypatch.setattr(photon_attenuation, "_get_photon_data", lambda _: None) + + xs_sum = linear_attenuation_xs("Og") + assert xs_sum is None + +def test_linear_attenuation_xs_gives_error_wrong_name(monkeypatch): + """Non existant nuclides should raise Value Error""" + monkeypatch.setattr(photon_attenuation, "_get_photon_data", lambda _: None) + + with pytest.raises(ValueError): + _ = linear_attenuation_xs("NonExisting123") + +# ================================================================ +# Tests for _get_photon_data (internal helper) +# ================================================================ + + +def test_get_photon_data_valid(xs_filename): + """_get_photon_data should load an IncidentPhoton object from the + cross sections library and cache it. + """ + lib = DataLibrary.from_xml(xs_filename) + + photon_nuclides = [mat for mat in lib if "photon" in mat["type"]] + if not photon_nuclides: + pytest.skip("No photon data entries available in cross section library.") + + nuclide = photon_nuclides[0]["materials"][0] + + # Clear internal cache + photon_attenuation._PHOTON_LIB = None + photon_attenuation._PHOTON_DATA = {} + + # Call target function + data1 = photon_attenuation._get_photon_data(nuclide) + + assert isinstance(data1, IncidentPhoton) + + # Cached instance should be reused on repeated calls + data2 = photon_attenuation._get_photon_data(nuclide) + assert data1 is data2 # same object, cached + + +def test_get_photon_data_missing_nuclide(): + """_get_photon_data should return None when the nuclide has no photon data.""" + photon_attenuation._PHOTON_LIB = None + photon_attenuation._PHOTON_DATA = {} + + # Pick a nuclide name guaranteed *not* to have data + name_no_data = "Og" + + data = photon_attenuation._get_photon_data(name_no_data) + assert data is None + +def test_get_photon_data_wrong_name(): + """_get_photon_data should return None when the nuclide does not exist.""" + photon_attenuation._PHOTON_LIB = None + photon_attenuation._PHOTON_DATA = {} + + # Pick a nuclide name guaranteed *not* to exist + bad_name = "ThisNuclideDoesNotExist123" + + data = photon_attenuation._get_photon_data(bad_name) + assert data is None + +def test_get_photon_data_no_library(monkeypatch): + """If DataLibrary.from_xml() fails, _get_photon_data should raise DataError.""" + # Force DataLibrary.from_xml to throw + monkeypatch.setattr( + photon_attenuation.DataLibrary, + "from_xml", + lambda *_, **kw: (kw, (_ for _ in ()).throw(IOError("missing file")))[1], + ) + + # Clear caches + photon_attenuation._PHOTON_LIB = None + photon_attenuation._PHOTON_DATA = {} + + with pytest.raises(DataError): + photon_attenuation._get_photon_data("U235") + + +def test_linear_attenuation_reference_values(elements_photon_xs, monkeypatch): + """Check linear_attenuation_xs for Pb and V at two reference energies.""" + openmc.reset_auto_ids() + pb_data = elements_photon_xs.get("Pb") + v_data = elements_photon_xs.get("V") + + if pb_data is None or v_data is None: + pytest.skip("Pb or V photon data not available in cross section library.") + + # Route _get_photon_data to our preloaded IncidentPhoton objects + def _fake_get_photon_data(name: str): + if name == "Pb": + return pb_data + if name == "V": + return v_data + return None + + monkeypatch.setattr(photon_attenuation, "_get_photon_data", _fake_get_photon_data) + + + # Call the helper + xs_pb = linear_attenuation_xs("Pb") + xs_v = linear_attenuation_xs("V") + + if xs_pb is None or xs_v is None: + pytest.skip("No relevant photon reactions for Pb or V.") + + assert isinstance(xs_pb, Sum) + assert isinstance(xs_v, Sum) + + # Test Lead + pb_energies = np.array([1.0e5, 1.0e6]) + pb_vals = xs_pb(pb_energies) + + # data from https://physics.nist.gov/PhysRefData/XrayMassCoef/ElemTab/z82.html + expected_pb = np.array( + [ + 5.549e00, + 7.102e-02, + ] + ) + + pb_mat = openmc.Material() + pb_mat.add_element("Pb", 1.0) + pb_mat.set_density("g/cm3", 11.34) + + expected_pb *= pb_mat.get_mass_density()/pb_mat.get_element_atom_densities()["Pb"] + + # Test Vanadium + v_energies = np.array([1.0e5, 1.0e6]) + v_vals = xs_v(v_energies) + + # data from https://physics.nist.gov/PhysRefData/XrayMassCoef/ElemTab/z23.html + expected_v = np.array( + [ + 2.877e-01, + 5.794e-02, + ] + ) + + v_mat = openmc.Material() + v_mat.add_element("V", 1.0) + v_mat.set_density("g/cm3", 11.34) + + expected_v *= pb_mat.get_mass_density()/v_mat.get_element_atom_densities()["V"] + + + # Replace with tighter tolerances once real values are in + assert np.allclose(pb_vals, expected_pb, rtol = 1e-2, atol=0) + assert np.allclose(v_vals, expected_v, rtol = 1e-2, atol=0) + + diff --git a/tests/unit_tests/test_material.py b/tests/unit_tests/test_material.py index 764c98d41ae..6009e373bfd 100644 --- a/tests/unit_tests/test_material.py +++ b/tests/unit_tests/test_material.py @@ -1,4 +1,5 @@ from collections import defaultdict +import os from pathlib import Path import pytest @@ -7,7 +8,11 @@ import openmc from openmc.data import decay_photon_energy +from openmc.data import IncidentPhoton +from openmc.data.library import DataLibrary from openmc.deplete import Chain +import openmc.data.photon_attenuation as photon_attenuation +from openmc.data.photon_attenuation import linear_attenuation_xs import openmc.examples import openmc.model import openmc.stats @@ -819,3 +824,131 @@ def test_material_from_constructor(): assert mat2.density == 1e-7 assert mat2.density_units == "g/cm3" assert mat2.nuclides == [] + +# test of the photon mass attenuation distribution generator + +@pytest.fixture(scope="module") +def xs_filename(): + xs = os.environ.get("OPENMC_CROSS_SECTIONS") + if xs is None: + pytest.skip("OPENMC_CROSS_SECTIONS not set.") + return xs + + +@pytest.fixture(scope="module") +def elements_photon_xs(xs_filename): + """Dictionary of IncidentPhoton data indexed by atomic symbol.""" + lib = DataLibrary.from_xml(xs_filename) + + elements = ["H", "O", "Al", "C", "Ag", "U", "Pb", "V"] + data = {} + for symbol in elements: + entry = lib.get_by_material(symbol, data_type="photon") + if entry is None: + continue + data[symbol] = IncidentPhoton.from_hdf5(entry["path"]) + return data + + + +def test_photon_mass_attenuation_returns_none_when_no_photon_data(monkeypatch): + """If no constituent has photon data, should return None.""" + openmc.reset_auto_ids() + # Make both element lookups return None + monkeypatch.setattr(photon_attenuation, "_get_photon_data", lambda _: None) + + mat = openmc.Material() + mat.add_element("C", 1.0) + mat.add_element("Pb", 1.0) + mat.set_density("g/cm3", 1.0) + + out = mat.get_photon_mass_attenuation() + assert out is None + + +@pytest.mark.parametrize("symbol", ["C", "Pb"]) +def test_photon_mass_attenuation_single_element_matches_linear_over_rho( + elements_photon_xs, symbol, monkeypatch +): + """For a pure element: μ/ρ(E) == (N*σ(E))/ρ == linear_attenuation_xs(E)/ρ.""" + openmc.reset_auto_ids() + element = elements_photon_xs.get(symbol) + if element is None: + pytest.skip(f"No photon data for {symbol} in cross section library.") + + # Route _get_photon_data to preloaded element data + monkeypatch.setattr(photon_attenuation, "_get_photon_data", lambda name: element if name == symbol else None) + + if symbol == "Pb": + rho = 11.34 + elif symbol == "C": + rho = 2.0 + else: + rho = 1.0 + + mat = openmc.Material() + mat.add_element(symbol, 1.0) + mat.set_density("g/cm3", rho) + + xs = linear_attenuation_xs(symbol) + if xs is None: + pytest.skip(f"No relevant photon reactions for {symbol}.") + + mu_over_rho = mat.get_photon_mass_attenuation() + assert mu_over_rho is not None + + energy = np.logspace(2, 6, 80) + + + rho = mat.get_mass_density() + n_el = mat.get_element_atom_densities()[symbol] + expected = xs(energy) * (n_el / rho) + actual = mu_over_rho(energy) + + + + assert np.allclose(actual, expected) + + +def test_photon_mass_attenuation_mixture_matches_explicit_sum( + elements_photon_xs, monkeypatch +): + """For a mixture: μ/ρ(E) == (Σ_i N_i σ_i(E))/ρ.""" + openmc.reset_auto_ids() + c_data = elements_photon_xs.get("C") + pb_data = elements_photon_xs.get("Pb") + if c_data is None or pb_data is None: + pytest.skip("C or Pb photon data not available in cross section library.") + + def _fake_get_photon_data(name: str): + if name == "C": + return c_data + if name == "Pb": + return pb_data + return None + + monkeypatch.setattr(photon_attenuation, "_get_photon_data", _fake_get_photon_data) + + rho = 7.0 + + mat = openmc.Material() + mat.add_element("C", 0.5) + mat.add_element("Pb", 0.5) + mat.set_density("g/cm3", rho) + + mu_over_rho = mat.get_photon_mass_attenuation() + if mu_over_rho is None: + pytest.skip("No relevant photon reactions for C/Pb.") + + # Explicit construction using the same building blocks: + el_dens = mat.get_element_atom_densities() + xs_c = linear_attenuation_xs("C") + xs_pb = linear_attenuation_xs("Pb") + if xs_c is None or xs_pb is None: + pytest.skip("No relevant photon reactions for C or Pb.") + + energy = np.logspace(2, 6, 80) + expected = (el_dens["C"] * xs_c(energy) + el_dens["Pb"] * xs_pb(energy)) / rho + actual = mu_over_rho(energy) + + assert np.allclose(actual, expected) diff --git a/tests/unit_tests/test_mesh.py b/tests/unit_tests/test_mesh.py index 9aca8b59656..89f64f1c933 100644 --- a/tests/unit_tests/test_mesh.py +++ b/tests/unit_tests/test_mesh.py @@ -610,6 +610,7 @@ def test_mesh_get_homogenized_materials(): @pytest.fixture def sphere_model(): + openmc.reset_auto_ids() # Model with three materials separated by planes x=0 and z=0 mats = [] for i in range(3):