From 728562ffd0030920af241142bbb58a4e4fdf8637 Mon Sep 17 00:00:00 2001 From: Julian Christian Sanders <209085623+JROChub@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:03:45 -0700 Subject: [PATCH] Preserve photon-data source metadata Capture each ENDF evaluation's library, version, and release separately for photoatomic and atomic-relaxation components. Persist optional attributes on the existing element and subshells groups, preserving the root layout and compatibility with files that omit source metadata. Validate metadata before opening output files. Add extraction, round-trip, legacy compatibility, data-integrity, and invalid-input coverage, with an inspection example and the optional attributes in the format guide. --- docs/source/io_formats/nuclear_data.rst | 12 ++ docs/source/usersguide/data.rst | 17 ++ openmc/data/photon.py | 77 ++++++- tests/unit_tests/test_data_photon.py | 257 ++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 3 deletions(-) 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