From aa909157a009aed7987fc477a01d8ce1e01372f2 Mon Sep 17 00:00:00 2001 From: Perry Date: Fri, 22 May 2026 12:15:17 -0700 Subject: [PATCH 01/23] Speed up check_iterable_type for numpy float/complex arrays Float/complex ndarrays are dtype-validated, so the per-element isinstance() scan is redundant. Also construct upper_ww_bounds in WeightWindows.__init__ as an ndarray multiplication (not a list comprehension) so the upper-bounds setter benefits too. ~11x speedup on 172M-element wwinp inputs (397 s -> 35 s). --- openmc/checkvalue.py | 11 +++++++++++ openmc/weight_windows.py | 4 +--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/openmc/checkvalue.py b/openmc/checkvalue.py index 5ff2cf9ac5a..e368494de3e 100644 --- a/openmc/checkvalue.py +++ b/openmc/checkvalue.py @@ -80,7 +80,18 @@ def check_iterable_type(name, value, expected_type, min_depth=1, max_depth=1): max_depth : int The maximum number of layers of nested iterables there should be before reaching the ultimately contained items + + Notes + ----- + numpy float/complex ndarrays whose number of dimensions falls within + [``min_depth``, ``max_depth``], the dtype is trusted to guarantee element + type and the per-element scan is skipped, which allows faster processing. """ + # Fast path: float/complex ndarrays of correct depth are dtype-validated. + if (isinstance(value, np.ndarray) and value.dtype.kind in 'fc' + and min_depth <= value.ndim <= max_depth): + return + # Initialize the tree at the very first item. tree = [value] index = [0] diff --git a/openmc/weight_windows.py b/openmc/weight_windows.py index 63af2596efc..99bcc12d019 100644 --- a/openmc/weight_windows.py +++ b/openmc/weight_windows.py @@ -140,9 +140,7 @@ def __init__( "upper_bound_ratio must be present.") if upper_bound_ratio: - self.upper_ww_bounds = [ - lb * upper_bound_ratio for lb in self.lower_ww_bounds - ] + self.upper_ww_bounds = self.lower_ww_bounds * upper_bound_ratio if upper_ww_bounds is not None: self.upper_ww_bounds = upper_ww_bounds From 6c4ad0938650cb08151eeb17e0ce94e48091b9eb Mon Sep 17 00:00:00 2001 From: Perry Date: Fri, 22 May 2026 12:15:36 -0700 Subject: [PATCH 02/23] Bypass XML round-trip in WeightWindowsList.export_to_hdf5 The XML serialization raised MemoryError on bound arrays >~200M elements -- lxml's intermediate ASCII allocation fails before the text node can be built. Write HDF5 directly via h5py, mirroring the C++ WeightWindows::to_hdf5 writer. Critical details for C++ compatibility: - Bounds are 2D (ne, n_voxels) on disk (4D would segfault the C++ tensor::Tensor reader). - max_lower_bound_ratio is written unconditionally (default 1.0). - Root attrs filetype and version are required by openmc_weight_windows_import. Adds Mesh.to_hdf5 on each structured mesh subclass, mirroring the existing Mesh.to_xml_element pattern. UnstructuredMesh raises NotImplementedError (wwinp cannot produce one). --- openmc/mesh.py | 77 +++++++++++++++++++ openmc/weight_windows.py | 71 +++++++++++++---- .../unit_tests/weightwindows/test_ww_list.py | 23 ++++++ 3 files changed, 154 insertions(+), 17 deletions(-) diff --git a/openmc/mesh.py b/openmc/mesh.py index 61effd850de..0713d4d30c7 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -306,6 +306,29 @@ def from_hdf5(cls, group: h5py.Group): else: raise ValueError('Unrecognized mesh type: "' + mesh_type + '"') + def to_hdf5(self, group: h5py.Group) -> h5py.Group: + """Write this mesh into *group* as a subgroup named ``mesh ``. + + Subclasses override this method to call ``super().to_hdf5(group)``, + write a ``type`` dataset, and append type-specific grid data. + + .. versionadded:: 0.15.4 + + Parameters + ---------- + group : h5py.Group + Parent HDF5 group (typically ``/meshes``). + + Returns + ------- + mesh_group : h5py.Group + The created ``mesh `` subgroup, ready for subclass extension. + """ + mesh_group = group.create_group(f'mesh {self.id}') + mesh_group.attrs['id'] = np.int32(self.id) + mesh_group.create_dataset('name', data=np.bytes_(self.name or '')) + return mesh_group + def to_xml_element(self): """Return XML representation of the mesh @@ -1231,6 +1254,18 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): return mesh + def to_hdf5(self, group: h5py.Group): + mesh_group = super().to_hdf5(group) + mesh_group.create_dataset('type', data=np.bytes_('regular')) + mesh_group.create_dataset( + 'dimension', data=np.asarray(self.dimension, dtype=np.int32)) + mesh_group.create_dataset( + 'lower_left', data=np.asarray(self.lower_left, dtype=float)) + mesh_group.create_dataset( + 'upper_right', data=np.asarray(self.upper_right, dtype=float)) + mesh_group.create_dataset( + 'width', data=np.asarray(self.width, dtype=float)) + @classmethod def from_rect_lattice( cls, @@ -1708,6 +1743,16 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): return mesh + def to_hdf5(self, group: h5py.Group): + mesh_group = super().to_hdf5(group) + mesh_group.create_dataset('type', data=np.bytes_('rectilinear')) + mesh_group.create_dataset( + 'x_grid', data=np.asarray(self.x_grid, dtype=float)) + mesh_group.create_dataset( + 'y_grid', data=np.asarray(self.y_grid, dtype=float)) + mesh_group.create_dataset( + 'z_grid', data=np.asarray(self.z_grid, dtype=float)) + @classmethod def from_xml_element(cls, elem: ET.Element): """Generate a rectilinear mesh from an XML element @@ -2104,6 +2149,18 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): return mesh + def to_hdf5(self, group: h5py.Group): + mesh_group = super().to_hdf5(group) + mesh_group.create_dataset('type', data=np.bytes_('cylindrical')) + mesh_group.create_dataset( + 'r_grid', data=np.asarray(self.r_grid, dtype=float)) + mesh_group.create_dataset( + 'phi_grid', data=np.asarray(self.phi_grid, dtype=float)) + mesh_group.create_dataset( + 'z_grid', data=np.asarray(self.z_grid, dtype=float)) + mesh_group.create_dataset( + 'origin', data=np.asarray(self.origin, dtype=float)) + @classmethod def from_bounding_box( cls, @@ -2476,6 +2533,18 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): return mesh + def to_hdf5(self, group: h5py.Group): + mesh_group = super().to_hdf5(group) + mesh_group.create_dataset('type', data=np.bytes_('spherical')) + mesh_group.create_dataset( + 'r_grid', data=np.asarray(self.r_grid, dtype=float)) + mesh_group.create_dataset( + 'theta_grid', data=np.asarray(self.theta_grid, dtype=float)) + mesh_group.create_dataset( + 'phi_grid', data=np.asarray(self.phi_grid, dtype=float)) + mesh_group.create_dataset( + 'origin', data=np.asarray(self.origin, dtype=float)) + @classmethod def from_bounding_box( cls, @@ -3220,6 +3289,14 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): return mesh + def to_hdf5(self, group: h5py.Group): + # Raise before super() so no half-built 'mesh ' group is left on disk. + raise NotImplementedError( + "UnstructuredMesh.to_hdf5 is not implemented in Python. " + "Use openmc.lib.export_weight_windows() to export weight " + "windows on unstructured meshes." + ) + def to_xml_element(self): """Return XML representation of the mesh diff --git a/openmc/weight_windows.py b/openmc/weight_windows.py index 99bcc12d019..1daa5b71abf 100644 --- a/openmc/weight_windows.py +++ b/openmc/weight_windows.py @@ -1061,29 +1061,66 @@ def from_wwinp(cls, path: PathLike) -> Self: def export_to_hdf5(self, path: PathLike = 'weight_windows.h5', **init_kwargs): """Write weight windows to an HDF5 file. + Writes the file directly via :mod:`h5py`, mirroring the layout + produced by :func:`openmc.lib.export_weight_windows`. The previous + XML round-trip raised :class:`MemoryError` on multi-GB bound arrays + because of the intermediate ASCII allocation inside lxml. + Parameters ---------- path : PathLike Path to the file to write weight windows to **init_kwargs - Keyword arguments passed to :func:`openmc.lib.init` - + Unused. Retained for backward compatibility (previously forwarded + to :func:`openmc.lib.init`). """ - import openmc.lib cv.check_type('path', path, PathLike) - - # Create a temporary model with the weight windows - model = openmc.Model() - sph = openmc.Sphere(boundary_type='vacuum') - cell = openmc.Cell(region=-sph) - model.geometry = openmc.Geometry([cell]) - model.settings.weight_windows = self - model.settings.particles = 100 - model.settings.batches = 1 - - # Get absolute path before moving to temporary directory path = Path(path).resolve() - # Load the model with openmc.lib and then export it to an HDF5 file - with openmc.lib.TemporarySession(model, **init_kwargs): - openmc.lib.export_weight_windows(path) + with h5py.File(path, 'w') as f: + f.attrs['filetype'] = np.bytes_('weight_windows') + f.attrs['version'] = np.asarray([1, 0], dtype=np.int32) + + meshes_grp = f.create_group('meshes') + wws_grp = f.create_group('weight_windows') + + seen_mesh_ids = set() + ww_ids = [] + for ww in self: + if ww.mesh.id not in seen_mesh_ids: + ww.mesh.to_hdf5(meshes_grp) + seen_mesh_ids.add(ww.mesh.id) + + g = wws_grp.create_group(f'weight_windows_{ww.id}') + ww_ids.append(int(ww.id)) + + g.create_dataset('mesh', data=np.int32(ww.mesh.id)) + g.create_dataset('particle_type', + data=np.bytes_(str(ww.particle_type))) + g.create_dataset('energy_bounds', + data=np.asarray(ww.energy_bounds, dtype=float)) + + # 2D (ne, n_voxels) C-contiguous: the C++ reader expects this layout. + ne = ww.lower_ww_bounds.shape[-1] + lo = np.ascontiguousarray(ww.lower_ww_bounds.T).reshape(ne, -1) + up = np.ascontiguousarray(ww.upper_ww_bounds.T).reshape(ne, -1) + g.create_dataset('lower_ww_bounds', data=lo) + g.create_dataset('upper_ww_bounds', data=up) + + g.create_dataset('survival_ratio', + data=float(ww.survival_ratio)) + # max_lower_bound_ratio is read unconditionally by C++; default 1.0 when unset. + mlbr = ww.max_lower_bound_ratio + g.create_dataset( + 'max_lower_bound_ratio', + data=float(mlbr if mlbr is not None else 1.0), + ) + g.create_dataset('max_split', data=np.int32(ww.max_split)) + g.create_dataset('weight_cutoff', + data=float(ww.weight_cutoff)) + + wws_grp.attrs['ids'] = np.asarray(ww_ids, dtype=np.int32) + wws_grp.attrs['n_weight_windows'] = np.int32(len(ww_ids)) + meshes_grp.attrs['ids'] = np.asarray(sorted(seen_mesh_ids), + dtype=np.int32) + meshes_grp.attrs['n_meshes'] = np.int32(len(seen_mesh_ids)) diff --git a/tests/unit_tests/weightwindows/test_ww_list.py b/tests/unit_tests/weightwindows/test_ww_list.py index d148f382a53..17200a656d4 100644 --- a/tests/unit_tests/weightwindows/test_ww_list.py +++ b/tests/unit_tests/weightwindows/test_ww_list.py @@ -1,3 +1,4 @@ +import h5py import openmc @@ -21,3 +22,25 @@ def test_ww_roundtrip(request, run_in_tmpdir): assert ww.max_split == ww_new.max_split assert ww.weight_cutoff == ww_new.weight_cutoff assert ww.mesh.id == ww_new.mesh.id + + +def test_export_hdf5_format(request, run_in_tmpdir): + # C++ openmc_weight_windows_import expects this on-disk layout. + wws = openmc.WeightWindowsList.from_wwinp(request.path.with_name('wwinp_n')) + wws.export_to_hdf5('ww.h5') + with h5py.File('ww.h5') as f: + assert f.attrs['filetype'] == b'weight_windows' + assert list(f.attrs['version']) == [1, 0] + wws_grp = f['weight_windows'] + assert int(wws_grp.attrs['n_weight_windows']) == len(wws) + for ww in wws: + g = wws_grp[f'weight_windows_{ww.id}'] + # 2D shape is required by the C++ tensor::Tensor reader. + assert g['lower_ww_bounds'].ndim == 2 + assert g['lower_ww_bounds'].shape[0] == ww.num_energy_bins + # max_lower_bound_ratio is read unconditionally by C++. + assert 'max_lower_bound_ratio' in g + m_grp = f['meshes'] + for name in m_grp: + assert 'id' in m_grp[name].attrs + assert 'type' in m_grp[name] From 9817c8e7017a59a26a2a7f48f7322c84bb139092 Mon Sep 17 00:00:00 2001 From: Perry Date: Fri, 29 May 2026 17:35:44 -0700 Subject: [PATCH 03/23] Tighten check_iterable_type fast path: validate expected_type The dtype-trust fast path returned for any float/complex ndarray of matching depth, even when expected_type was int or another class -- the docstring promised element-type validation but the fast path skipped it. Gate the fast path on expected_type in (Real, float, complex) so it only fires when dtype.kind in 'fc' actually satisfies the contract. --- openmc/checkvalue.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openmc/checkvalue.py b/openmc/checkvalue.py index e368494de3e..8537cff8263 100644 --- a/openmc/checkvalue.py +++ b/openmc/checkvalue.py @@ -1,6 +1,7 @@ import copy import os from collections.abc import Iterable +from numbers import Real import numpy as np @@ -83,13 +84,16 @@ def check_iterable_type(name, value, expected_type, min_depth=1, max_depth=1): Notes ----- - numpy float/complex ndarrays whose number of dimensions falls within - [``min_depth``, ``max_depth``], the dtype is trusted to guarantee element - type and the per-element scan is skipped, which allows faster processing. + For numpy float/complex ndarrays whose ``ndim`` is within + ``[min_depth, max_depth]`` and whose ``expected_type`` is ``Real``, + ``float``, or ``complex``, the dtype guarantees the element type and the + per-element scan is skipped for faster processing. """ - # Fast path: float/complex ndarrays of correct depth are dtype-validated. + # Fast path: float/complex ndarrays whose dtype already guarantees the + # expected_type can skip the per-element scan. if (isinstance(value, np.ndarray) and value.dtype.kind in 'fc' - and min_depth <= value.ndim <= max_depth): + and min_depth <= value.ndim <= max_depth + and expected_type in (Real, float, complex)): return # Initialize the tree at the very first item. From cf1c44f28a7983074e55c8da402b08e934f3ad5f Mon Sep 17 00:00:00 2001 From: Perry Date: Fri, 29 May 2026 17:36:38 -0700 Subject: [PATCH 04/23] Restore UnstructuredMesh support via TemporarySession fallback The direct-h5py writer cannot serialize an UnstructuredMesh from pure Python: vertex and connectivity data live in the external .exo/.h5m file and only exist in memory after LibMesh/MOAB loads them via openmc.lib.init. Dispatch on mesh type up front: structured meshes take the new fast path; UnstructuredMesh falls back to the previous TemporarySession + openmc.lib.export_weight_windows route, which also restores honoring of init_kwargs on that path. Removes the dead NotImplementedError branch from _write_mesh_group. --- openmc/weight_windows.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/openmc/weight_windows.py b/openmc/weight_windows.py index 1daa5b71abf..355c9eed976 100644 --- a/openmc/weight_windows.py +++ b/openmc/weight_windows.py @@ -1061,22 +1061,38 @@ def from_wwinp(cls, path: PathLike) -> Self: def export_to_hdf5(self, path: PathLike = 'weight_windows.h5', **init_kwargs): """Write weight windows to an HDF5 file. - Writes the file directly via :mod:`h5py`, mirroring the layout - produced by :func:`openmc.lib.export_weight_windows`. The previous - XML round-trip raised :class:`MemoryError` on multi-GB bound arrays - because of the intermediate ASCII allocation inside lxml. + Structured-mesh weight windows are written directly via :mod:`h5py`, + avoiding the multi-GB ASCII intermediate that previously caused + :class:`MemoryError` on large wwinp inputs. + + Weight windows on an :class:`~openmc.UnstructuredMesh` require + LibMesh or MOAB (loaded by :func:`openmc.lib.init`) to materialize + the mesh's vertex and connectivity data; for those, this method + falls back to :func:`openmc.lib.export_weight_windows`. Parameters ---------- path : PathLike Path to the file to write weight windows to **init_kwargs - Unused. Retained for backward compatibility (previously forwarded - to :func:`openmc.lib.init`). + Forwarded to :func:`openmc.lib.init` when the UnstructuredMesh + fallback path is used. Unused for the direct h5py path. """ cv.check_type('path', path, PathLike) path = Path(path).resolve() + if any(isinstance(ww.mesh, UnstructuredMesh) for ww in self): + import openmc.lib + model = openmc.Model() + sph = openmc.Sphere(boundary_type='vacuum') + model.geometry = openmc.Geometry([openmc.Cell(region=-sph)]) + model.settings.weight_windows = self + model.settings.particles = 100 + model.settings.batches = 1 + with openmc.lib.TemporarySession(model, **init_kwargs): + openmc.lib.export_weight_windows(path) + return + with h5py.File(path, 'w') as f: f.attrs['filetype'] = np.bytes_('weight_windows') f.attrs['version'] = np.asarray([1, 0], dtype=np.int32) From 40539f1ad68d7caab62f401c9c403e3c98f2df80 Mon Sep 17 00:00:00 2001 From: Perry Date: Sat, 30 May 2026 07:40:59 -0700 Subject: [PATCH 05/23] Comment cleanup --- openmc/checkvalue.py | 3 +-- openmc/weight_windows.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openmc/checkvalue.py b/openmc/checkvalue.py index 8537cff8263..c8a9f1bdbb8 100644 --- a/openmc/checkvalue.py +++ b/openmc/checkvalue.py @@ -89,8 +89,7 @@ def check_iterable_type(name, value, expected_type, min_depth=1, max_depth=1): ``float``, or ``complex``, the dtype guarantees the element type and the per-element scan is skipped for faster processing. """ - # Fast path: float/complex ndarrays whose dtype already guarantees the - # expected_type can skip the per-element scan. + # Fast path: trusted dtype skips the per-element scan (see Notes). if (isinstance(value, np.ndarray) and value.dtype.kind in 'fc' and min_depth <= value.ndim <= max_depth and expected_type in (Real, float, complex)): diff --git a/openmc/weight_windows.py b/openmc/weight_windows.py index 355c9eed976..94dcb70ff63 100644 --- a/openmc/weight_windows.py +++ b/openmc/weight_windows.py @@ -1081,6 +1081,7 @@ def export_to_hdf5(self, path: PathLike = 'weight_windows.h5', **init_kwargs): cv.check_type('path', path, PathLike) path = Path(path).resolve() + # Any unstructured mesh forces the whole list onto the lib fallback. if any(isinstance(ww.mesh, UnstructuredMesh) for ww in self): import openmc.lib model = openmc.Model() From ef5c165344fc35bd73922a46b943d57bd8c74f67 Mon Sep 17 00:00:00 2001 From: Perry Date: Mon, 1 Jun 2026 01:26:11 -0700 Subject: [PATCH 06/23] Drop complex case from check_iterable_type fast path --- openmc/checkvalue.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openmc/checkvalue.py b/openmc/checkvalue.py index c8a9f1bdbb8..490a431e343 100644 --- a/openmc/checkvalue.py +++ b/openmc/checkvalue.py @@ -84,15 +84,15 @@ def check_iterable_type(name, value, expected_type, min_depth=1, max_depth=1): Notes ----- - For numpy float/complex ndarrays whose ``ndim`` is within - ``[min_depth, max_depth]`` and whose ``expected_type`` is ``Real``, - ``float``, or ``complex``, the dtype guarantees the element type and the - per-element scan is skipped for faster processing. + For numpy float ndarrays whose ``ndim`` is within + ``[min_depth, max_depth]`` and whose ``expected_type`` is ``Real`` or + ``float``, the dtype guarantees the element type and the per-element scan + is skipped for faster processing. """ - # Fast path: trusted dtype skips the per-element scan (see Notes). - if (isinstance(value, np.ndarray) and value.dtype.kind in 'fc' + # Fast path: trusted float dtype skips the per-element scan (see Notes). + if (isinstance(value, np.ndarray) and value.dtype.kind == 'f' and min_depth <= value.ndim <= max_depth - and expected_type in (Real, float, complex)): + and expected_type in (Real, float)): return # Initialize the tree at the very first item. From ef108cbe7e994caa60454580a2b95c490375ecd2 Mon Sep 17 00:00:00 2001 From: Perry Date: Mon, 1 Jun 2026 01:26:24 -0700 Subject: [PATCH 07/23] Make MeshBase.to_hdf5 abstract; drop stale comment Declaring to_hdf5 as an abstractmethod documents that every mesh type implements it --- openmc/mesh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmc/mesh.py b/openmc/mesh.py index 0713d4d30c7..df54051aade 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -306,6 +306,7 @@ def from_hdf5(cls, group: h5py.Group): else: raise ValueError('Unrecognized mesh type: "' + mesh_type + '"') + @abstractmethod def to_hdf5(self, group: h5py.Group) -> h5py.Group: """Write this mesh into *group* as a subgroup named ``mesh ``. @@ -3290,7 +3291,6 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): return mesh def to_hdf5(self, group: h5py.Group): - # Raise before super() so no half-built 'mesh ' group is left on disk. raise NotImplementedError( "UnstructuredMesh.to_hdf5 is not implemented in Python. " "Use openmc.lib.export_weight_windows() to export weight " From 2dedf306b1a3ff4532eedbdad96ad35e18937c6f Mon Sep 17 00:00:00 2001 From: Perry Date: Mon, 1 Jun 2026 01:41:24 -0700 Subject: [PATCH 08/23] Return subgroup from structured mesh to_hdf5 overrides --- openmc/mesh.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openmc/mesh.py b/openmc/mesh.py index df54051aade..57b5c6b3890 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -1266,6 +1266,7 @@ def to_hdf5(self, group: h5py.Group): 'upper_right', data=np.asarray(self.upper_right, dtype=float)) mesh_group.create_dataset( 'width', data=np.asarray(self.width, dtype=float)) + return mesh_group @classmethod def from_rect_lattice( @@ -1753,6 +1754,7 @@ def to_hdf5(self, group: h5py.Group): 'y_grid', data=np.asarray(self.y_grid, dtype=float)) mesh_group.create_dataset( 'z_grid', data=np.asarray(self.z_grid, dtype=float)) + return mesh_group @classmethod def from_xml_element(cls, elem: ET.Element): @@ -2161,6 +2163,7 @@ def to_hdf5(self, group: h5py.Group): 'z_grid', data=np.asarray(self.z_grid, dtype=float)) mesh_group.create_dataset( 'origin', data=np.asarray(self.origin, dtype=float)) + return mesh_group @classmethod def from_bounding_box( @@ -2545,6 +2548,7 @@ def to_hdf5(self, group: h5py.Group): 'phi_grid', data=np.asarray(self.phi_grid, dtype=float)) mesh_group.create_dataset( 'origin', data=np.asarray(self.origin, dtype=float)) + return mesh_group @classmethod def from_bounding_box( From f1c85fc2c8062a1ec5bbd9723fe745819a28b3ff Mon Sep 17 00:00:00 2001 From: Perry Date: Mon, 1 Jun 2026 12:05:45 -0700 Subject: [PATCH 09/23] Add UnstructuredMesh weight-window export test --- .../unit_tests/weightwindows/test_ww_list.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit_tests/weightwindows/test_ww_list.py b/tests/unit_tests/weightwindows/test_ww_list.py index 17200a656d4..7c334dac86e 100644 --- a/tests/unit_tests/weightwindows/test_ww_list.py +++ b/tests/unit_tests/weightwindows/test_ww_list.py @@ -1,4 +1,6 @@ import h5py +import numpy as np +import pytest import openmc @@ -44,3 +46,26 @@ def test_export_hdf5_format(request, run_in_tmpdir): for name in m_grp: assert 'id' in m_grp[name].attrs assert 'type' in m_grp[name] + + +@pytest.mark.parametrize('library', ('libmesh', 'moab')) +def test_export_hdf5_unstructured_mesh(request, run_in_tmpdir, library): + # UnstructuredMesh can't be serialized from pure Python; export_to_hdf5 + # routes it through openmc.lib.export_weight_windows (a live session). + if library == 'libmesh' and not openmc.lib._libmesh_enabled(): + pytest.skip('LibMesh not enabled in this build.') + if library == 'moab' and not openmc.lib._dagmc_enabled(): + pytest.skip('DAGMC (and MOAB) not enabled in this build.') + + mesh = openmc.UnstructuredMesh( + str(request.path.with_name('test_mesh_tets.exo')), library) + ww = openmc.WeightWindows(mesh, np.ones((12_000,)), upper_bound_ratio=5.0) + openmc.WeightWindowsList([ww]).export_to_hdf5('ww.h5') + + with h5py.File('ww.h5') as f: + assert f.attrs['filetype'] == b'weight_windows' + assert list(f.attrs['version']) == [1, 0] + assert int(f['weight_windows'].attrs['n_weight_windows']) == 1 + m_grp = f['meshes'][f'mesh {mesh.id}'] + assert m_grp['type'][()] == b'unstructured' + assert 'filename' in m_grp From 44f0967cd5873cb9815b3dfc8c4186adc6e9e34f Mon Sep 17 00:00:00 2001 From: GuySten Date: Tue, 2 Jun 2026 01:05:42 +0300 Subject: [PATCH 10/23] implement to_hdf5 for unstructured meshes using a new c api function --- openmc/lib/mesh.py | 14 ++++++++++++-- openmc/mesh.py | 17 +++++++++++++---- openmc/weight_windows.py | 13 ------------- src/mesh.cpp | 21 +++++++++++++++++++++ 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/openmc/lib/mesh.py b/openmc/lib/mesh.py index 19e6f74d7ad..f55e13bb71c 100644 --- a/openmc/lib/mesh.py +++ b/openmc/lib/mesh.py @@ -1,5 +1,5 @@ from collections.abc import Mapping, Sequence -from ctypes import (c_int, c_int32, c_char_p, c_double, POINTER, c_void_p, +from ctypes import (c_int, c_int32, c_int64, c_char_p, c_double, POINTER, c_void_p, create_string_buffer, c_size_t) from math import sqrt import sys @@ -18,7 +18,7 @@ __all__ = [ 'Mesh', 'RegularMesh', 'RectilinearMesh', 'CylindricalMesh', - 'SphericalMesh', 'UnstructuredMesh', 'meshes', 'MeshMaterialVolumes' + 'SphericalMesh', 'UnstructuredMesh', 'meshes', 'MeshMaterialVolumes', 'export_unstructured_mesh' ] @@ -108,6 +108,10 @@ _dll.openmc_spherical_mesh_set_grid.restype = c_int _dll.openmc_spherical_mesh_set_grid.errcheck = _error_handler +_dll.openmc_unstructured_mesh_export_hdf5.argtypes = [c_int32, c_int64] +_dll.openmc_unstructured_mesh_export_hdf5.restype = c_int +_dll.openmc_unstructured_mesh_export_hdf5.errcheck = _error_handler + class Mesh(_FortranObjectWithID): """Base class to represent mesh objects @@ -740,6 +744,12 @@ class UnstructuredMesh(Mesh): } +def export_unstructured_mesh(mesh, group): + index = c_int32() + _dll.openmc_get_mesh_index(mesh.id, index) + _dll.openmc_unstructured_mesh_export_hdf5(index.value, int(group.id.id)) + + def _get_mesh(index): mesh_type = create_string_buffer(20) _dll.openmc_mesh_get_type(index, mesh_type) diff --git a/openmc/mesh.py b/openmc/mesh.py index 57b5c6b3890..ab2ae4eacb3 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -3295,11 +3295,20 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): return mesh def to_hdf5(self, group: h5py.Group): - raise NotImplementedError( - "UnstructuredMesh.to_hdf5 is not implemented in Python. " - "Use openmc.lib.export_weight_windows() to export weight " - "windows on unstructured meshes." + import openmc.lib + mesh_group = super().to_hdf5(group) + model = openmc.Model() + sph = openmc.Sphere(boundary_type='vacuum') + model.geometry = openmc.Geometry([openmc.Cell(region=-sph)]) + model.settings.particles = 100 + model.settings.batches = 1 + wwg = openmc.WeightWindowGenerator( + method='magic', + mesh=self, ) + model.settings.weight_window_generators = [wwg] + with openmc.lib.TemporarySession(model): + openmc.lib.export_unstructured_mesh(self, mesh_group) def to_xml_element(self): """Return XML representation of the mesh diff --git a/openmc/weight_windows.py b/openmc/weight_windows.py index 94dcb70ff63..609dc1e8ee3 100644 --- a/openmc/weight_windows.py +++ b/openmc/weight_windows.py @@ -1081,19 +1081,6 @@ def export_to_hdf5(self, path: PathLike = 'weight_windows.h5', **init_kwargs): cv.check_type('path', path, PathLike) path = Path(path).resolve() - # Any unstructured mesh forces the whole list onto the lib fallback. - if any(isinstance(ww.mesh, UnstructuredMesh) for ww in self): - import openmc.lib - model = openmc.Model() - sph = openmc.Sphere(boundary_type='vacuum') - model.geometry = openmc.Geometry([openmc.Cell(region=-sph)]) - model.settings.weight_windows = self - model.settings.particles = 100 - model.settings.batches = 1 - with openmc.lib.TemporarySession(model, **init_kwargs): - openmc.lib.export_weight_windows(path) - return - with h5py.File(path, 'w') as f: f.attrs['filetype'] = np.bytes_('weight_windows') f.attrs['version'] = np.asarray([1, 0], dtype=np.int32) diff --git a/src/mesh.cpp b/src/mesh.cpp index 5ab7ac3988b..630af410ce6 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -2843,6 +2843,27 @@ extern "C" int openmc_spherical_mesh_set_grid(int32_t index, index, grid_x, nx, grid_y, ny, grid_z, nz); } +extern "C" int openmc_unstructured_mesh_export_hdf5( + int32_t index, hid_t mesh_group) +{ + if (int err = check_mesh_type(index)) + return err; + UnstructuredMesh* m = + dynamic_cast(model::meshes[index].get()); + + // Write mesh type + write_dataset(mesh_group, "type", m->get_mesh_type()); + + // Write mesh ID + write_attribute(mesh_group, "id", m->id_); + + // Write mesh name + write_dataset(mesh_group, "name", m->name_); + + m->to_hdf5_inner(mesh_group); + return 0; +} + #ifdef OPENMC_DAGMC_ENABLED const std::string MOABMesh::mesh_lib_type = "moab"; From d7379bea977e3877c45898fe35ed4104c904d6c1 Mon Sep 17 00:00:00 2001 From: GuySten Date: Tue, 2 Jun 2026 01:12:48 +0300 Subject: [PATCH 11/23] fix docstring --- openmc/weight_windows.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openmc/weight_windows.py b/openmc/weight_windows.py index 609dc1e8ee3..dc44bac8f9e 100644 --- a/openmc/weight_windows.py +++ b/openmc/weight_windows.py @@ -1058,7 +1058,7 @@ def from_wwinp(cls, path: PathLike) -> Self: return wws - def export_to_hdf5(self, path: PathLike = 'weight_windows.h5', **init_kwargs): + def export_to_hdf5(self, path: PathLike = 'weight_windows.h5'): """Write weight windows to an HDF5 file. Structured-mesh weight windows are written directly via :mod:`h5py`, @@ -1068,15 +1068,12 @@ def export_to_hdf5(self, path: PathLike = 'weight_windows.h5', **init_kwargs): Weight windows on an :class:`~openmc.UnstructuredMesh` require LibMesh or MOAB (loaded by :func:`openmc.lib.init`) to materialize the mesh's vertex and connectivity data; for those, this method - falls back to :func:`openmc.lib.export_weight_windows`. + uses the c-api under the hood. Parameters ---------- path : PathLike Path to the file to write weight windows to - **init_kwargs - Forwarded to :func:`openmc.lib.init` when the UnstructuredMesh - fallback path is used. Unused for the direct h5py path. """ cv.check_type('path', path, PathLike) path = Path(path).resolve() From 4418acdb62d37d33e8ee97bdb8af2f8ee2833f4e Mon Sep 17 00:00:00 2001 From: GuySten Date: Tue, 2 Jun 2026 20:51:23 +0300 Subject: [PATCH 12/23] free weight windows generators memory --- openmc/mesh.py | 2 ++ src/weight_windows.cpp | 1 + 2 files changed, 3 insertions(+) diff --git a/openmc/mesh.py b/openmc/mesh.py index ab2ae4eacb3..73e82223257 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -3296,6 +3296,7 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): def to_hdf5(self, group: h5py.Group): import openmc.lib + mesh_group = super().to_hdf5(group) model = openmc.Model() sph = openmc.Sphere(boundary_type='vacuum') @@ -3309,6 +3310,7 @@ def to_hdf5(self, group: h5py.Group): model.settings.weight_window_generators = [wwg] with openmc.lib.TemporarySession(model): openmc.lib.export_unstructured_mesh(self, mesh_group) + return mesh_group def to_xml_element(self): """Return XML representation of the mesh diff --git a/src/weight_windows.cpp b/src/weight_windows.cpp index 0614110cd32..433ebf0c9b9 100644 --- a/src/weight_windows.cpp +++ b/src/weight_windows.cpp @@ -1036,6 +1036,7 @@ void free_memory_weight_windows() { variance_reduction::ww_map.clear(); variance_reduction::weight_windows.clear(); + variance_reduction::weight_windows_generators.clear(); } void finalize_variance_reduction() From aea9e631ddb77295ce8314855ee11b5017abea55 Mon Sep 17 00:00:00 2001 From: GuySten Date: Tue, 2 Jun 2026 21:50:29 +0300 Subject: [PATCH 13/23] try skip writing on master --- src/mesh.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mesh.cpp b/src/mesh.cpp index 630af410ce6..9fb7d59fc83 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -2846,6 +2846,9 @@ extern "C" int openmc_spherical_mesh_set_grid(int32_t index, extern "C" int openmc_unstructured_mesh_export_hdf5( int32_t index, hid_t mesh_group) { + if (!mpi::master) + return 0; + if (int err = check_mesh_type(index)) return err; UnstructuredMesh* m = From e5224b6ad7253eabe3b1adc5f35099d18ca48641 Mon Sep 17 00:00:00 2001 From: GuySten Date: Tue, 2 Jun 2026 22:24:19 +0300 Subject: [PATCH 14/23] dynamically infer hid_t size --- openmc/lib/mesh.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openmc/lib/mesh.py b/openmc/lib/mesh.py index f55e13bb71c..f1820effa6a 100644 --- a/openmc/lib/mesh.py +++ b/openmc/lib/mesh.py @@ -5,6 +5,7 @@ import sys from weakref import WeakValueDictionary +import h5py import numpy as np from numpy.ctypeslib import as_array @@ -22,6 +23,11 @@ ] +if (h5py.h5.get_libversion() >= (1, 12, 1)): + c_hid_t = c_int64 +else: + c_hid_t = c_int32 + arr_2d_int32 = np.ctypeslib.ndpointer(dtype=np.int32, ndim=2, flags='CONTIGUOUS') arr_2d_double = np.ctypeslib.ndpointer(dtype=np.double, ndim=2, flags='CONTIGUOUS') @@ -108,7 +114,7 @@ _dll.openmc_spherical_mesh_set_grid.restype = c_int _dll.openmc_spherical_mesh_set_grid.errcheck = _error_handler -_dll.openmc_unstructured_mesh_export_hdf5.argtypes = [c_int32, c_int64] +_dll.openmc_unstructured_mesh_export_hdf5.argtypes = [c_int32, c_hid_t] _dll.openmc_unstructured_mesh_export_hdf5.restype = c_int _dll.openmc_unstructured_mesh_export_hdf5.errcheck = _error_handler From c148888ec94f79d9c4b7b9c986c04dde18abc94a Mon Sep 17 00:00:00 2001 From: GuySten Date: Tue, 2 Jun 2026 23:59:39 +0300 Subject: [PATCH 15/23] add reset auto ids --- tests/unit_tests/weightwindows/test_ww_list.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit_tests/weightwindows/test_ww_list.py b/tests/unit_tests/weightwindows/test_ww_list.py index 7c334dac86e..c2ea8eae77c 100644 --- a/tests/unit_tests/weightwindows/test_ww_list.py +++ b/tests/unit_tests/weightwindows/test_ww_list.py @@ -5,6 +5,8 @@ def test_ww_roundtrip(request, run_in_tmpdir): + openmc.reset_auto_ids() + # Load weight windows from a wwinp file wwinp_file = request.path.with_name('wwinp_n') wws = openmc.WeightWindowsList.from_wwinp(wwinp_file) @@ -27,6 +29,8 @@ def test_ww_roundtrip(request, run_in_tmpdir): def test_export_hdf5_format(request, run_in_tmpdir): + openmc.reset_auto_ids() + # C++ openmc_weight_windows_import expects this on-disk layout. wws = openmc.WeightWindowsList.from_wwinp(request.path.with_name('wwinp_n')) wws.export_to_hdf5('ww.h5') @@ -50,6 +54,8 @@ def test_export_hdf5_format(request, run_in_tmpdir): @pytest.mark.parametrize('library', ('libmesh', 'moab')) def test_export_hdf5_unstructured_mesh(request, run_in_tmpdir, library): + openmc.reset_auto_ids() + # UnstructuredMesh can't be serialized from pure Python; export_to_hdf5 # routes it through openmc.lib.export_weight_windows (a live session). if library == 'libmesh' and not openmc.lib._libmesh_enabled(): From bb28b2814f762e1ce88a87b5c8503b9dbc161dde Mon Sep 17 00:00:00 2001 From: GuySten Date: Wed, 3 Jun 2026 00:25:15 +0300 Subject: [PATCH 16/23] fix missing include and hid_t type inference --- include/openmc/capi.h | 1 + openmc/lib/mesh.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/include/openmc/capi.h b/include/openmc/capi.h index 911654d318f..44ebaeeb4a3 100644 --- a/include/openmc/capi.h +++ b/include/openmc/capi.h @@ -74,6 +74,7 @@ int openmc_get_n_batches(int* n_batches, bool get_max_batches); int openmc_get_nuclide_index(const char name[], int* index); int openmc_add_unstructured_mesh( const char filename[], const char library[], int* id); +int openmc_unstructured_mesh_export_hdf5(int32_t index, hid_t mesh_group); int64_t openmc_get_seed(); uint64_t openmc_get_stride(); int openmc_get_tally_index(int32_t id, int32_t* index); diff --git a/openmc/lib/mesh.py b/openmc/lib/mesh.py index f1820effa6a..4835aad1b5d 100644 --- a/openmc/lib/mesh.py +++ b/openmc/lib/mesh.py @@ -23,7 +23,7 @@ ] -if (h5py.h5.get_libversion() >= (1, 12, 1)): +if (h5py.h5.get_libversion() >= (1, 10, 0)): c_hid_t = c_int64 else: c_hid_t = c_int32 From e2efaf4b4d8b9c82b4a6b8428af8dde798bc62c2 Mon Sep 17 00:00:00 2001 From: GuySten Date: Wed, 3 Jun 2026 00:28:42 +0300 Subject: [PATCH 17/23] fix import --- include/openmc/capi.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/openmc/capi.h b/include/openmc/capi.h index 44ebaeeb4a3..4893f7aafdf 100644 --- a/include/openmc/capi.h +++ b/include/openmc/capi.h @@ -1,6 +1,8 @@ #ifndef OPENMC_CAPI_H #define OPENMC_CAPI_H +#include "hdf5.h" + #include #include #include From a4bf0823fe4b8e5e27f84cd6bdf12ba22f55df17 Mon Sep 17 00:00:00 2001 From: GuySten Date: Wed, 3 Jun 2026 07:19:37 +0300 Subject: [PATCH 18/23] [gha-debug] From 52829aac43ceaab369052c7ef48fd1137d940a70 Mon Sep 17 00:00:00 2001 From: GuySten Date: Wed, 3 Jun 2026 08:15:22 +0300 Subject: [PATCH 19/23] [gha-debug] fix tmate did not start on failure --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c14279b92..231c1303978 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,7 @@ jobs: - name: Setup tmate debug session continue-on-error: true - if: ${{ contains(env.COMMIT_MESSAGE, '[gha-debug]') }} + if: failure() && ${{ contains(env.COMMIT_MESSAGE, '[gha-debug]') }} uses: mxschmitt/action-tmate@v3 timeout-minutes: 10 From f49b59a2c3621049a80cc91c19a4de10a978136c Mon Sep 17 00:00:00 2001 From: GuySten Date: Wed, 3 Jun 2026 15:37:01 +0300 Subject: [PATCH 20/23] fix syntax [gha-debug] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 231c1303978..c3da79c0482 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,7 @@ jobs: - name: Setup tmate debug session continue-on-error: true - if: failure() && ${{ contains(env.COMMIT_MESSAGE, '[gha-debug]') }} + if: ${{ failure() && contains(env.COMMIT_MESSAGE, '[gha-debug]') }} uses: mxschmitt/action-tmate@v3 timeout-minutes: 10 From 50288aa80abbfdd732f04da6930996ee0c630161 Mon Sep 17 00:00:00 2001 From: GuySten Date: Wed, 3 Jun 2026 17:26:09 +0300 Subject: [PATCH 21/23] [gha-debug] From f3c8a0cef3879a9350c5c26e256fd0d07d251015 Mon Sep 17 00:00:00 2001 From: GuySten Date: Wed, 3 Jun 2026 21:19:31 +0300 Subject: [PATCH 22/23] [gha-debug] open debug session --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3da79c0482..5474b79fe76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,7 +179,7 @@ jobs: continue-on-error: true if: ${{ failure() && contains(env.COMMIT_MESSAGE, '[gha-debug]') }} uses: mxschmitt/action-tmate@v3 - timeout-minutes: 10 + timeout-minutes: 100 - name: Generate C++ coverage (gcovr) shell: bash From d050943e3d9147177974dee1f48e9d8c4b1bd42b Mon Sep 17 00:00:00 2001 From: GuySten Date: Wed, 3 Jun 2026 23:36:41 +0300 Subject: [PATCH 23/23] try another fix --- openmc/mesh.py | 4 +--- src/mesh.cpp | 11 +---------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/openmc/mesh.py b/openmc/mesh.py index d52d1d53984..a771b426c57 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -3367,7 +3367,6 @@ def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): def to_hdf5(self, group: h5py.Group): import openmc.lib - mesh_group = super().to_hdf5(group) model = openmc.Model() sph = openmc.Sphere(boundary_type='vacuum') model.geometry = openmc.Geometry([openmc.Cell(region=-sph)]) @@ -3379,8 +3378,7 @@ def to_hdf5(self, group: h5py.Group): ) model.settings.weight_window_generators = [wwg] with openmc.lib.TemporarySession(model): - openmc.lib.export_unstructured_mesh(self, mesh_group) - return mesh_group + openmc.lib.export_unstructured_mesh(self, group) def to_xml_element(self): """Return XML representation of the mesh diff --git a/src/mesh.cpp b/src/mesh.cpp index 9fb7d59fc83..97cf7105969 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -2854,16 +2854,7 @@ extern "C" int openmc_unstructured_mesh_export_hdf5( UnstructuredMesh* m = dynamic_cast(model::meshes[index].get()); - // Write mesh type - write_dataset(mesh_group, "type", m->get_mesh_type()); - - // Write mesh ID - write_attribute(mesh_group, "id", m->id_); - - // Write mesh name - write_dataset(mesh_group, "name", m->name_); - - m->to_hdf5_inner(mesh_group); + m->to_hdf5(mesh_group); return 0; }