diff --git a/docs/source/io_formats/nuclear_data.rst b/docs/source/io_formats/nuclear_data.rst index 68d0e343d3f..6832fa02456 100644 --- a/docs/source/io_formats/nuclear_data.rst +++ b/docs/source/io_formats/nuclear_data.rst @@ -126,6 +126,12 @@ Incident Photon Data **//** :Attributes: - **Z** (*int*) -- Atomic number + - **source_library** (*string*) -- Photoatomic evaluation library + name (optional) + - **source_version** (*int*) -- Photoatomic evaluation library + version (optional) + - **source_release** (*int*) -- Photoatomic evaluation library + release (optional) :Datasets: - **energy** (*double[]*) -- Energies in [eV] at which cross sections @@ -192,6 +198,12 @@ Incident Photon Data **//subshells/** :Attributes: - **designators** (*char[][]*) -- Designator for each shell, e.g. 'M2' + - **source_library** (*string*) -- Atomic-relaxation evaluation + library name (optional) + - **source_version** (*int*) -- Atomic-relaxation evaluation + library version (optional) + - **source_release** (*int*) -- Atomic-relaxation evaluation + library release (optional) **//subshells//** diff --git a/docs/source/usersguide/data.rst b/docs/source/usersguide/data.rst index 8b2938556be..d7a8e1d6fe0 100644 --- a/docs/source/usersguide/data.rst +++ b/docs/source/usersguide/data.rst @@ -247,6 +247,23 @@ relaxation sublibrary files are required: u = openmc.data.IncidentPhoton.from_endf('photoat-092_U_000.endf', 'atom-092_U_000.endf') +The source library name, version, and release from each ENDF evaluation are +retained separately. After saving and reloading the data, inspect the +photoatomic source on :attr:`IncidentPhoton.source_metadata` and the +relaxation source on :attr:`AtomicRelaxation.source_metadata`:: + + u.export_to_hdf5('U.h5') + restored = openmc.data.IncidentPhoton.from_hdf5('U.h5') + print(restored.source_metadata) + print(restored.atomic_relaxation.source_metadata) + +Each dictionary contains the available ``library`` (string), ``version`` +(integer), and ``release`` (integer) fields. These identify the respective +evaluations, not the other bundled components such as Compton profiles or +bremsstrahlung data. Files without this information, including older HDF5 +files, load with empty dictionaries. Source information is not inferred when +reading ACE data. + Once the HDF5 files have been generated, a library can be created using the :class:`DataLibrary` class as described in :ref:`create_xs_library`. diff --git a/openmc/data/photon.py b/openmc/data/photon.py index 13cd3ec95f8..0489df76d9e 100644 --- a/openmc/data/photon.py +++ b/openmc/data/photon.py @@ -105,6 +105,47 @@ _BREMSSTRAHLUNG = {} +def _check_source_metadata(metadata): + """Validate the supported source fields before opening an output file.""" + cv.check_type('source metadata', metadata, Mapping) + for key, value in metadata.items(): + cv.check_value('source metadata field', key, + ('library', 'version', 'release')) + expected_type = str if key == 'library' else Integral + cv.check_type(f'source {key}', value, expected_type) + if key == 'library': + value.encode('utf-8') + if '\x00' in value: + raise ValueError('Source library cannot contain null bytes') + else: + cv.check_greater_than(f'source {key}', value, -(1 << 63), True) + cv.check_less_than(f'source {key}', value, (1 << 64) - 1, True) + + +def _read_source_metadata(group): + """Read optional component source attributes as Python scalars.""" + metadata = {} + for key in ('library', 'version', 'release'): + if f'source_{key}' in group.attrs: + value = group.attrs[f'source_{key}'] + if key == 'library': + if isinstance(value, bytes): + value = value.decode('utf-8') + else: + cv.check_type(f'source {key}', value, Integral) + value = int(value) + metadata[key] = value + _check_source_metadata(metadata) + return metadata + + +def _write_source_metadata(group, metadata): + """Write source attributes without adding entries to the group.""" + for key, value in metadata.items(): + value = str(value) if key == 'library' else int(value) + group.attrs[f'source_{key}'] = value + + class AtomicRelaxation(EqualityMixin): """Atomic relaxation data. @@ -145,6 +186,11 @@ class AtomicRelaxation(EqualityMixin): strings, e.g., 'K', 'L1', 'L2', etc. subshells : list List of subshells as strings, e.g. ``['K', 'L1', ...]`` + source_metadata : dict + Source of the atomic-relaxation evaluation, with keys ``'library'`` + (str), ``'version'`` (int), and ``'release'`` (int). Unknown fields + are absent. Empty for data without source information, including + older HDF5 files and data read from ACE. transitions : pandas.DataFrame Dictionary indicating allowed transitions and their probabilities (values) for given subshells (keys). The subshells should be given as @@ -163,6 +209,7 @@ def __init__(self, binding_energy, num_electrons, transitions): self.num_electrons = num_electrons self.transitions = transitions self._e_fluorescence = {} + self.source_metadata = {} @property def binding_energy(self): @@ -323,8 +370,11 @@ def from_endf(cls, ev_or_filename): transitions[subi] = pd.DataFrame.from_records( records, columns=columns) - # Return instance of class - return cls(binding_energy, num_electrons, transitions) + data = cls(binding_energy, num_electrons, transitions) + library, version, release = ev.info['library'] + data.source_metadata = { + 'library': library, 'version': version, 'release': release} + return data @classmethod def from_hdf5(cls, group): @@ -368,7 +418,9 @@ def from_hdf5(cls, group): np.arange(float(len(_SUBSHELLS))), _SUBSHELLS) transitions[shell] = df - return cls(binding_energy, num_electrons, transitions) + data = cls(binding_energy, num_electrons, transitions) + data.source_metadata = _read_source_metadata(group) + return data def to_hdf5(self, group, shell): """Write atomic relaxation data to an HDF5 group @@ -436,6 +488,12 @@ class IncidentPhoton(EqualityMixin): reactions : dict Contains the cross sections for each photon reaction. The keys are MT values and the values are instances of :class:`PhotonReaction`. + source_metadata : dict + Source of the photoatomic evaluation, with keys ``'library'`` (str), + ``'version'`` (int), and ``'release'`` (int). Unknown fields are + absent. Empty for data without source information, including older + HDF5 files and data read from ACE. The atomic-relaxation source is + stored separately on :attr:`atomic_relaxation`. """ @@ -445,6 +503,7 @@ def __init__(self, atomic_number): self.reactions = {} self.compton_profiles = {} self.bremsstrahlung = {} + self.source_metadata = {} def __contains__(self, mt): return mt in self.reactions @@ -621,6 +680,9 @@ def from_endf(cls, photoatomic, relaxation=None): Z = ev.target['atomic_number'] data = cls(Z) + library, version, release = ev.info['library'] + data.source_metadata = { + 'library': library, 'version': version, 'release': release} # Read each reaction for mf, mt, nc, mod in ev.reaction_list: @@ -695,6 +757,7 @@ def from_hdf5(cls, group_or_filename): Z = group.attrs['Z'] data = cls(Z) + data.source_metadata = _read_source_metadata(group) # Read energy grid energy = group['energy'][()] @@ -761,6 +824,10 @@ def export_to_hdf5(self, path, mode='a', libver='earliest'): that are less backwards compatible but have performance benefits. """ + _check_source_metadata(self.source_metadata) + if self.atomic_relaxation is not None: + _check_source_metadata(self.atomic_relaxation.source_metadata) + with h5py.File(str(path), mode, libver=libver) as f: # Write filetype and version f.attrs['filetype'] = np.bytes_('data_photon') @@ -769,6 +836,7 @@ def export_to_hdf5(self, path, mode='a', libver='earliest'): group = f.create_group(self.name) group.attrs['Z'] = Z = self.atomic_number + _write_source_metadata(group, self.source_metadata) # Determine union energy grid union_grid = np.array([]) @@ -778,6 +846,9 @@ def export_to_hdf5(self, path, mode='a', libver='earliest'): # Write cross sections shell_group = group.create_group('subshells') + if self.atomic_relaxation is not None: + _write_source_metadata( + shell_group, self.atomic_relaxation.source_metadata) designators = [] for mt, rx in self.reactions.items(): name, key = _REACTION_NAME[mt] diff --git a/tests/unit_tests/test_data_photon.py b/tests/unit_tests/test_data_photon.py index aceb82c3ab7..88a99006a5e 100644 --- a/tests/unit_tests/test_data_photon.py +++ b/tests/unit_tests/test_data_photon.py @@ -1,7 +1,9 @@ from collections.abc import Mapping, Callable +from copy import deepcopy import os from pathlib import Path +import h5py import numpy as np import pandas as pd import pytest @@ -173,3 +175,258 @@ def test_atomic_relaxation_from_endf_material(endf_data): assert data.binding_energy['K'] == pytest.approx(13.61) assert data.num_electrons['K'] == pytest.approx(1.0) + + +@pytest.fixture(scope='module') +def photon_evaluations(endf_data): + endf_dir = Path(endf_data) + paths = ( + endf_dir / 'photoat' / 'photoat-001_H_000.endf', + endf_dir / 'atomic_relax' / 'atom-001_H_000.endf', + ) + return tuple(openmc.data.endf.Evaluation(path) for path in paths) + + +@pytest.fixture +def photon_with_metadata(photon_evaluations): + photoatomic, relaxation = deepcopy(photon_evaluations) + photoatomic.info['library'] = ('Photoatomic evaluation', 8, 1) + relaxation.info['library'] = ('Relaxation evaluation', 7, 3) + return openmc.data.IncidentPhoton.from_endf(photoatomic, relaxation) + + +@pytest.mark.parametrize( + 'input_type', ['str', 'path', 'evaluation', 'material']) +def test_source_metadata_from_endf(endf_data, photon_evaluations, input_type): + """Extract each component's own ENDF library, version and release.""" + inputs = [ + Path(endf_data) / 'photoat' / 'photoat-001_H_000.endf', + Path(endf_data) / 'atomic_relax' / 'atom-001_H_000.endf', + ] + if input_type == 'str': + inputs = [str(path) for path in inputs] + elif input_type == 'evaluation': + inputs = deepcopy(photon_evaluations) + elif input_type == 'material': + inputs = [openmc.data.endf.get_evaluations(path)[0] for path in inputs] + + data = openmc.data.IncidentPhoton.from_endf(*inputs) + standalone = openmc.data.AtomicRelaxation.from_endf(inputs[1]) + for component, evaluation in zip( + (data, data.atomic_relaxation), photon_evaluations): + library, version, release = evaluation.info['library'] + assert component.source_metadata == { + 'library': library, 'version': version, 'release': release} + assert standalone.source_metadata == data.atomic_relaxation.source_metadata + + +def test_source_metadata_components_are_independent(photon_evaluations): + """Keep different evaluations and their mutable source records separate.""" + photoatomic, relaxation = deepcopy(photon_evaluations) + photoatomic.info['library'] = ('Photoatomic evaluation', 8, 1) + relaxation.info['library'] = ('Relaxation evaluation', 7, 3) + data = openmc.data.IncidentPhoton.from_endf(photoatomic, relaxation) + + assert data.source_metadata == { + 'library': 'Photoatomic evaluation', 'version': 8, 'release': 1} + assert data.atomic_relaxation.source_metadata == { + 'library': 'Relaxation evaluation', 'version': 7, 'release': 3} + photoatomic.info['library'] = ('Changed input', 99, 99) + data.source_metadata['version'] = 9 + assert data.source_metadata['library'] == 'Photoatomic evaluation' + assert data.atomic_relaxation.source_metadata['version'] == 7 + assert relaxation.info['library'] == ('Relaxation evaluation', 7, 3) + + +def test_source_metadata_hdf5_roundtrip(tmp_path, photon_with_metadata): + """Round-trip independent records through paths and HDF5 groups.""" + path = tmp_path / 'photon.h5' + data = photon_with_metadata + data.export_to_hdf5(path) + + for filename in (str(path), path): + restored = openmc.data.IncidentPhoton.from_hdf5(filename) + assert restored.source_metadata == data.source_metadata + assert (restored.atomic_relaxation.source_metadata == + data.atomic_relaxation.source_metadata) + for component in (restored, restored.atomic_relaxation): + assert isinstance(component.source_metadata['library'], str) + assert type(component.source_metadata['version']) is int + assert type(component.source_metadata['release']) is int + + with h5py.File(path, 'r') as h5file: + group = h5file[data.name] + restored = openmc.data.IncidentPhoton.from_hdf5(group) + standalone = openmc.data.AtomicRelaxation.from_hdf5(group['subshells']) + assert restored.source_metadata == data.source_metadata + assert (standalone.source_metadata == + data.atomic_relaxation.source_metadata) + for location, metadata in ( + (group, data.source_metadata), + (group['subshells'], data.atomic_relaxation.source_metadata)): + library = location.attrs['source_library'] + if isinstance(library, bytes): + library = library.decode('utf-8') + assert library == metadata['library'] + assert location.attrs['source_version'] == metadata['version'] + assert location.attrs['source_release'] == metadata['release'] + assert h5file.id.valid + + +def test_source_metadata_legacy_hdf5(tmp_path, photon_with_metadata): + """Read older files without inventing provenance for either source.""" + path = tmp_path / 'legacy.h5' + data = photon_with_metadata + data.export_to_hdf5(path) + original = openmc.data.IncidentPhoton.from_hdf5(path) + with h5py.File(path, 'r+') as h5file: + for group in (h5file[data.name], h5file[data.name]['subshells']): + for key in ('source_library', 'source_version', 'source_release'): + del group.attrs[key] + + restored = openmc.data.IncidentPhoton.from_hdf5(path) + assert restored.source_metadata == {} + assert restored.atomic_relaxation.source_metadata == {} + assert (restored.atomic_relaxation.binding_energy == + data.atomic_relaxation.binding_energy) + np.testing.assert_array_equal(restored[502].xs.y, original[502].xs.y) + + +def test_source_metadata_preserves_hdf5_payload( + tmp_path, photon_with_metadata): + """Keep numerical data, attributes and element registration unchanged.""" + enriched = tmp_path / 'with_metadata.h5' + plain = tmp_path / 'without_metadata.h5' + data = photon_with_metadata + data.export_to_hdf5(enriched) + without_metadata = deepcopy(data) + without_metadata.source_metadata = {} + without_metadata.atomic_relaxation.source_metadata = {} + without_metadata.export_to_hdf5(plain) + + metadata_keys = {'source_library', 'source_version', 'source_release'} + with h5py.File(enriched, 'r') as actual, h5py.File(plain, 'r') as expected: + actual_names, expected_names = [], [] + actual.visit(actual_names.append) + expected.visit(expected_names.append) + assert actual_names == expected_names + assert list(actual) == [data.name] + for name in ['', *actual_names]: + left, right = actual[name or '/'], expected[name or '/'] + assert set(left.attrs) - metadata_keys == set(right.attrs) + for key in right.attrs: + np.testing.assert_array_equal( + left.attrs[key], right.attrs[key]) + if isinstance(right, h5py.Dataset): + assert left.dtype == right.dtype + np.testing.assert_array_equal(left[()], right[()]) + + library = openmc.data.DataLibrary() + library.register_file(enriched) + assert len(library) == 1 + assert library[0]['type'] == 'photon' + assert library[0]['materials'] == [data.name] + + +def test_source_metadata_without_relaxation(tmp_path, photon_evaluations): + """Do not assign photoatomic provenance to absent relaxation data.""" + photoatomic, _ = photon_evaluations + data = openmc.data.IncidentPhoton.from_endf(photoatomic) + assert data.atomic_relaxation is None + library, version, release = photoatomic.info['library'] + assert data.source_metadata == { + 'library': library, 'version': version, 'release': release} + path = tmp_path / 'photoatomic.h5' + data.export_to_hdf5(path) + with h5py.File(path, 'r') as h5file: + subshells = h5file[data.name]['subshells'] + assert not any(key.startswith('source_') for key in subshells.attrs) + restored = openmc.data.IncidentPhoton.from_hdf5(path) + assert restored.source_metadata == data.source_metadata + assert restored.atomic_relaxation.source_metadata == {} + + +def test_source_metadata_defaults_are_independent(): + """Give new objects separate empty provenance dictionaries.""" + first = openmc.data.IncidentPhoton(1) + second = openmc.data.IncidentPhoton(1) + first_relaxation = openmc.data.AtomicRelaxation({}, {}, {}) + second_relaxation = openmc.data.AtomicRelaxation({}, {}, {}) + for component in (first, second, first_relaxation, second_relaxation): + assert component.source_metadata == {} + first.source_metadata['library'] = 'Photoatomic evaluation' + first_relaxation.source_metadata['library'] = 'Relaxation evaluation' + assert second.source_metadata == {} + assert second_relaxation.source_metadata == {} + + +@pytest.mark.parametrize('as_bytes', [False, True]) +def test_source_metadata_unicode(tmp_path, photon_with_metadata, as_bytes): + """Decode UTF-8 provenance without changing the numeric version fields.""" + data = photon_with_metadata + data.source_metadata['library'] = 'Évaluation photonique' + path = tmp_path / 'unicode.h5' + data.export_to_hdf5(path) + if as_bytes: + with h5py.File(path, 'r+') as h5file: + attrs = h5file[data.name].attrs + del attrs['source_library'] + attrs['source_library'] = np.bytes_( + data.source_metadata['library'].encode('utf-8')) + restored = openmc.data.IncidentPhoton.from_hdf5(path) + assert restored.source_metadata == data.source_metadata + + +def test_source_metadata_partial_records(tmp_path, photon_with_metadata): + """Preserve known fields without filling in absent component metadata.""" + data = photon_with_metadata + data.source_metadata = {'library': 'Partial photoatomic record'} + data.atomic_relaxation.source_metadata = {'version': 7} + path = tmp_path / 'partial.h5' + data.export_to_hdf5(path) + restored = openmc.data.IncidentPhoton.from_hdf5(path) + assert restored.source_metadata == data.source_metadata + assert (restored.atomic_relaxation.source_metadata == + data.atomic_relaxation.source_metadata) + + +def test_source_metadata_zero_versions(tmp_path, photon_with_metadata): + """Retain zero-valued NumPy integers as ordinary Python metadata.""" + data = photon_with_metadata + data.source_metadata = { + 'library': 'Zero version', + 'version': np.int32(0), + 'release': np.int64(0), + } + path = tmp_path / 'zero.h5' + data.export_to_hdf5(path) + restored = openmc.data.IncidentPhoton.from_hdf5(path) + assert restored.source_metadata == { + 'library': 'Zero version', 'version': 0, 'release': 0} + assert type(restored.source_metadata['version']) is int + assert type(restored.source_metadata['release']) is int + + +@pytest.mark.parametrize('component, metadata, error', [ + ('photoatomic', {'version': '8'}, TypeError), + ('relaxation', {'library': 7}, TypeError), + ('photoatomic', {'unrecognized': 'source'}, ValueError), + ('photoatomic', {'library': 'invalid\x00library'}, ValueError), + ('relaxation', {'library': '\ud800'}, UnicodeEncodeError), + ('photoatomic', {'version': 1 << 100}, ValueError), + ('relaxation', {'release': -(1 << 100)}, ValueError), +]) +def test_invalid_source_metadata_preserves_output( + tmp_path, photon_with_metadata, component, metadata, error): + """Reject invalid metadata before truncating an existing output file.""" + data = photon_with_metadata + target = data if component == 'photoatomic' else data.atomic_relaxation + target.source_metadata = metadata + path = tmp_path / 'existing.h5' + original = b'Existing output must survive invalid metadata.' + path.write_bytes(original) + + with pytest.raises(error): + data.export_to_hdf5(path, mode='w') + + assert path.read_bytes() == original