Repository: openmc-dev/openmc

Issue:
get UnstructuredMesh centroids and volumes without loading from statepoint
## Description
It would be useful if we can get mesh information such as volumes and centroids of the unstructured mesh. Currently this is possible if one loads in an unstructured mesh from a statepoint file but not if an UnstructuredMesh is instantiated in python like this
```
 umesh = openmc.UnstructuredMesh(filename="umesh.h5m",library='moab')
 umesh.centroids
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/j/openmc/openmc/mesh.py", line 2066, in centroids
    return np.array([self.centroid(i) for i in range(self.n_elements)])
  File "/home/j/openmc/openmc/mesh.py", line 2071, in n_elements
    raise RuntimeError("No information about this mesh has "
RuntimeError: No information about this mesh has been loaded from a statepoint file.
```

This would be useful as knowing the centroids or volumes could help with defining a openmc.MeshSource. It would also bring the UnstructuredMesh behaviour more inline with the other meshes such as RegularMesh


## Alternatives
We write the UnstructuredMesh to a statepoint file, then read in the statepoint file and get the UnstructuredMesh from hdf5 then it contains centroids and volume information


## Compatibility
this would maintain the same api usage


Discussion:
A good thought. I'm sure you can imagine why this is difficult from the Python API as the libraries we rely on for unstructured mesh (MOAB and libMesh) interact with OpennMC through a C++ interface. The mesh volumes are already exposed through the CAPI in the `openmc.lib` module. If the `centroids` were also to be added would that suit your needs?

A valid OpenMC problem would still need to be defined and loaded before determining your mesh source strengths, but maybe it's still useful?
latest attempt

```

import openmc
import openmc.lib

openmc.config['cross_sections'] = '/home/j/endf-b8.0-hdf5/endfb-viii.0-hdf5/cross_sections.xml'

umesh = openmc.UnstructuredMesh(filename="umesh.h5m",library='moab')

surf1 = openmc.Sphere(r=50000, boundary_type="vacuum")
region1 = -surf1

cell1 = openmc.Cell(region=region1)

my_geometry = openmc.Geometry([cell1])

my_source = openmc.IndependentSource()
my_source.space = openmc.stats.Point((0, 0, 0))
my_source.angle = openmc.stats.Isotropic()
my_source.energy = openmc.stats.Discrete([14e6], [1])

all_sources = [my_source] * 308
mesh_source = openmc.MeshSource(
    mesh=umesh,
    sources=all_sources,
)

my_settings = openmc.Settings()
my_settings.batches = 1
my_settings.particles = 1
my_settings.run_mode = "fixed source"
my_settings.source = mesh_source

model = openmc.model.Model(my_geometry, None, my_settings )
model.export_to_model_xml()

openmc.lib.init()
openmc.lib.UnstructuredMesh()
```
At the end, perhaps try: `unstructured_mesh = openmc.lib.meshes[umesh.id]`
Thanks Patrick

It is a bit tricky with the need to include the mesh in the model so that openmc.lib can find it.

Originally I tried to include it in the model via a MeshSource but that didn't work and raised the error 

```
"/home/j/openmc/openmc/source.py", line 503, in populate_xml_element
    for idx in self.mesh.indices:
AttributeError: 'UnstructuredMesh' object has no attribute 'indices'
```

```
umesh = openmc.UnstructuredMesh(filename="umesh.h5m",library='moab')
mesh_source = openmc.MeshSource(
    mesh=umesh,
    sources=all_sources,
)
settings.source = mesh_source # this line raises the error
```

So I shall include the mesh via a tally and then I should be able to get the fully populated unstrucutred mesh object and then I should be able to make a meshSource from it.

Adding centroids would be great. I think longer term we might want to add some extra code to meshSource or settings.source to simplify the process of having a meshsource with and unstrucutred mesh
update. I tried reading the unstructured mesh in from the statepoint and did get populated centroids and volumes. However it appears that an unstrucutred mesh can't be used as a mesh for an openmc.MeshSource. When trying to use a umesh read in from the statepoint we get the AttributeError

```
model_gamma.run(cwd="photons")
  File "/home/j/openmc/openmc/model/model.py", line 712, in run
    self.export_to_model_xml(**export_kwargs)
  File "/home/j/openmc/openmc/model/model.py", line 509, in export_to_model_xml
    settings_element = self.settings.to_xml_element(mesh_memo)
  File "/home/j/openmc/openmc/settings.py", line 1791, in to_xml_element
    self._create_source_subelement(element, mesh_memo)
  File "/home/j/openmc/openmc/settings.py", line 1076, in _create_source_subelement
    root.append(source.to_xml_element())
  File "/home/j/openmc/openmc/source.py", line 77, in to_xml_element
    self.populate_xml_element(element)
  File "/home/j/openmc/openmc/source.py", line 503, in populate_xml_element
    for idx in self.mesh.indices:
AttributeError: 'UnstructuredMesh' object has no attribute 'indices'
```

I tried to add that attribute with
```umesh_from_sp.indices = [i+1 for i in range(len(mesh_vols))]```

but this just raised a type error when trying to write the xml
```
model_gamma.run(cwd="photons")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/j/openmc/openmc/model/model.py", line 712, in run
    self.export_to_model_xml(**export_kwargs)
  File "/home/j/openmc/openmc/model/model.py", line 509, in export_to_model_xml
    settings_element = self.settings.to_xml_element(mesh_memo)
  File "/home/j/openmc/openmc/settings.py", line 1791, in to_xml_element
    self._create_source_subelement(element, mesh_memo)
  File "/home/j/openmc/openmc/settings.py", line 1076, in _create_source_subelement
    root.append(source.to_xml_element())
  File "/home/j/openmc/openmc/source.py", line 77, in to_xml_element
    self.populate_xml_element(element)
  File "/home/j/openmc/openmc/source.py", line 504, in populate_xml_element
    idx = tuple(i - 1 for i in idx)
TypeError: 'int' object is not iterable
```

I have a not so minimal example over [here](https://github.com/fusion-energy/neutronics-workshop/blob/3173f5b59ec1359367932cea898a7f2dff6c8422/tasks/task_18_CAD_shut_down_dose_rate/1_unstrucutred_mesh_R2S_shut_down_dose_rate_concise.py) if it helps
Glad to hear that reading the volumes and centroids went alright @shimwell! 

It seems we are in fact missing the indices property for the `UnstructuredMesh` class. This should really be present on the `MeshBase` class as an abstract method to protect from this as we expect all mesh implementations to have it. Should be an easy fix and I can update it shortly.

The reason that the population of the indices attribute you have above isn't working is because that property is expected to be a tuple of indices. See https://docs.openmc.org/en/latest/pythonapi/generated/openmc.RegularMesh.html?highlight=meshbase#openmc-regularmesh

Hopefully that helps you move forward in the meantime.

Thanks Patrick and Paul (on slack), I appreciate your help going through some of these less trodden workflows.

I changed
```
umesh_from_sp.indices = [i+1 for i in range(len(mesh_vols))]
```
to
```
umesh_from_sp.indices = [(i+1, i+1, i+1) for i in range(len(mesh_vols))]
```
in my example and it now writes the model.xml file, so it does get past that indices check. The code still fails on the run command as now later in the code it recieves a tuple with three values when unstructured meshes have 1D indexing.

```
    gamma_sp_filename = model_gamma.run(cwd="photons")
  File "/home/j/openmc/openmc/model/model.py", line 712, in run
    self.export_to_model_xml(**export_kwargs)
  File "/home/j/openmc/openmc/model/model.py", line 509, in export_to_model_xml
    settings_element = self.settings.to_xml_element(mesh_memo)
  File "/home/j/openmc/openmc/settings.py", line 1791, in to_xml_element
    self._create_source_subelement(element, mesh_memo)
  File "/home/j/openmc/openmc/settings.py", line 1076, in _create_source_subelement
    root.append(source.to_xml_element())
  File "/home/j/openmc/openmc/source.py", line 77, in to_xml_element
    self.populate_xml_element(element)
  File "/home/j/openmc/openmc/source.py", line 505, in populate_xml_element
    elem.append(self.sources[idx].to_xml_element())
IndexError: too many indices for array: array is 1-dimensional, but 3 were indexed
>>> 
```

So I guess the ```indices``` could be moved to ```MeshBase``` and used by regular mesh, spherical mesh etc but it the UnstrucutreMesh class might need its own implementation as it's indices appear to be 1D in places 

 

Resolving patch:
diff --git a/openmc/filter.py b/openmc/filter.py
--- a/openmc/filter.py
+++ b/openmc/filter.py
@@ -864,10 +864,10 @@ def mesh(self, mesh):
         cv.check_type('filter mesh', mesh, openmc.MeshBase)
         self._mesh = mesh
         if isinstance(mesh, openmc.UnstructuredMesh):
-            if mesh.volumes is None:
-                self.bins = []
-            else:
+            if mesh.has_statepoint_data:
                 self.bins = list(range(len(mesh.volumes)))
+            else:
+                self.bins = []
         else:
             self.bins = list(mesh.indices)
 
@@ -982,7 +982,7 @@ def from_xml_element(cls, elem, **kwargs):
         if translation:
             out.translation = [float(x) for x in translation.split()]
         return out
-    
+
 
 class MeshBornFilter(MeshFilter):
     """Filter events by the mesh cell a particle originated from.
diff --git a/openmc/mesh.py b/openmc/mesh.py
--- a/openmc/mesh.py
+++ b/openmc/mesh.py
@@ -3,6 +3,7 @@
 import warnings
 from abc import ABC, abstractmethod
 from collections.abc import Iterable
+from functools import wraps
 from math import pi, sqrt, atan2
 from numbers import Integral, Real
 from pathlib import Path
@@ -39,7 +40,8 @@ class MeshBase(IDManagerMixin, ABC):
     bounding_box : openmc.BoundingBox
         Axis-aligned bounding box of the mesh as defined by the upper-right and
         lower-left coordinates.
-
+    indices : Iterable of tuple
+        An iterable of mesh indices for each mesh element, e.g. [(1, 1, 1), (2, 1, 1), ...]
     """
 
     next_id = 1
@@ -66,6 +68,11 @@ def name(self, name: str):
     def bounding_box(self) -> openmc.BoundingBox:
         return openmc.BoundingBox(self.lower_left, self.upper_right)
 
+    @property
+    @abstractmethod
+    def indices(self):
+        pass
+
     def __repr__(self):
         string = type(self).__name__ + '\n'
         string += '{0: <16}{1}{2}\n'.format('\tID', '=\t', self._id)
@@ -1914,6 +1921,17 @@ def _convert_to_cartesian(arr, origin: Sequence[float]):
         return arr
 
 
+def require_statepoint_data(func):
+    @wraps(func)
+    def wrapper(self: UnstructuredMesh, *args, **kwargs):
+        if not self._has_statepoint_data:
+            raise AttributeError(f'The "{func.__name__}" property requires '
+                                 'information about this mesh to be loaded '
+                                 'from a statepoint file.')
+        return func(self, *args, **kwargs)
+    return wrapper
+
+
 class UnstructuredMesh(MeshBase):
     """A 3D unstructured mesh
 
@@ -1990,6 +2008,7 @@ def __init__(self, filename: PathLike, library: str, mesh_id: Optional[int] = No
         self.library = library
         self._output = False
         self.length_multiplier = length_multiplier
+        self._has_statepoint_data = False
 
     @property
     def filename(self):
@@ -2010,6 +2029,7 @@ def library(self, lib: str):
         self._library = lib
 
     @property
+    @require_statepoint_data
     def size(self):
         return self._size
 
@@ -2028,6 +2048,7 @@ def output(self, val: bool):
         self._output = val
 
     @property
+    @require_statepoint_data
     def volumes(self):
         """Return Volumes for every mesh cell if
         populated by a StatePoint file
@@ -2046,26 +2067,32 @@ def volumes(self, volumes: typing.Iterable[Real]):
         self._volumes = volumes
 
     @property
+    @require_statepoint_data
     def total_volume(self):
         return np.sum(self.volumes)
 
     @property
+    @require_statepoint_data
     def vertices(self):
         return self._vertices
 
     @property
+    @require_statepoint_data
     def connectivity(self):
         return self._connectivity
 
     @property
+    @require_statepoint_data
     def element_types(self):
         return self._element_types
 
     @property
+    @require_statepoint_data
     def centroids(self):
         return np.array([self.centroid(i) for i in range(self.n_elements)])
 
     @property
+    @require_statepoint_data
     def n_elements(self):
         if self._n_elements is None:
             raise RuntimeError("No information about this mesh has "
@@ -2096,6 +2123,15 @@ def dimension(self):
     def n_dimension(self):
         return 3
 
+    @property
+    @require_statepoint_data
+    def indices(self):
+        return [(i,) for i in range(self.n_elements)]
+
+    @property
+    def has_statepoint_data(self) -> bool:
+        return self._has_statepoint_data
+
     def __repr__(self):
         string = super().__repr__()
         string += '{: <16}=\t{}\n'.format('\tFilename', self.filename)
@@ -2106,13 +2142,16 @@ def __repr__(self):
         return string
 
     @property
+    @require_statepoint_data
     def lower_left(self):
         return self.vertices.min(axis=0)
 
     @property
+    @require_statepoint_data
     def upper_right(self):
         return self.vertices.max(axis=0)
 
+    @require_statepoint_data
     def centroid(self, bin: int):
         """Return the vertex averaged centroid of an element
 
@@ -2257,6 +2296,7 @@ def from_hdf5(cls, group: h5py.Group):
         library = group['library'][()].decode()
 
         mesh = cls(filename=filename, library=library, mesh_id=mesh_id)
+        mesh._has_statepoint_data = True
         vol_data = group['volumes'][()]
         mesh.volumes = np.reshape(vol_data, (vol_data.shape[0],))
         mesh.n_elements = mesh.volumes.size
diff --git a/openmc/source.py b/openmc/source.py
--- a/openmc/source.py
+++ b/openmc/source.py
@@ -500,9 +500,13 @@ def populate_xml_element(self, elem: ET.Element):
         elem.set("mesh", str(self.mesh.id))
 
         # write in the order of mesh indices
-        for idx in self.mesh.indices:
-            idx = tuple(i - 1 for i in idx)
-            elem.append(self.sources[idx].to_xml_element())
+        if isinstance(self.mesh, openmc.UnstructuredMesh):
+            for s in self.sources:
+                elem.append(s.to_xml_element())
+        else:
+            for idx in self.mesh.indices:
+                idx = tuple(i - 1 for i in idx)
+                elem.append(self.sources[idx].to_xml_element())
 
     @classmethod
     def from_xml_element(cls, elem: ET.Element, meshes) -> openmc.MeshSource:


Test patch:
diff --git a/tests/unit_tests/test_mesh.py b/tests/unit_tests/test_mesh.py
--- a/tests/unit_tests/test_mesh.py
+++ b/tests/unit_tests/test_mesh.py
@@ -298,3 +298,22 @@ def test_CylindricalMesh_get_indices_at_coords():
     assert mesh.get_indices_at_coords([98, 200.1, 299]) == (0, 1, 0)  # second angle quadrant
     assert mesh.get_indices_at_coords([98, 199.9, 299]) == (0, 2, 0)  # third angle quadrant
     assert mesh.get_indices_at_coords([102, 199.1, 299]) == (0, 3, 0)  # forth angle quadrant
+
+def test_umesh_roundtrip(run_in_tmpdir, request):
+    umesh = openmc.UnstructuredMesh(request.path.parent / 'test_mesh_tets.e', 'moab')
+    umesh.output = True
+
+    # create a tally using this mesh
+    mf = openmc.MeshFilter(umesh)
+    tally = openmc.Tally()
+    tally.filters = [mf]
+    tally.scores = ['flux']
+
+    tallies = openmc.Tallies([tally])
+    tallies.export_to_xml()
+
+    xml_tallies = openmc.Tallies.from_xml()
+    xml_tally = xml_tallies[0]
+    xml_mesh = xml_tally.filters[0].mesh
+
+    assert umesh.id == xml_mesh.id
diff --git a/tests/unit_tests/test_source_mesh.py b/tests/unit_tests/test_source_mesh.py
--- a/tests/unit_tests/test_source_mesh.py
+++ b/tests/unit_tests/test_source_mesh.py
@@ -208,23 +208,29 @@ def test_roundtrip(run_in_tmpdir, model, request):
 ###################
 # MeshSource tests
 ###################
-@pytest.mark.parametrize('mesh_type', ('rectangular', 'cylindrical'))
-def test_mesh_source_independent(run_in_tmpdir, mesh_type):
+@pytest.fixture
+def void_model():
     """
     A void model containing a single box
     """
-    min, max = -10, 10
-    box = openmc.model.RectangularParallelepiped(
-        min, max, min, max, min, max, boundary_type='vacuum')
+    model = openmc.Model()
 
-    geometry = openmc.Geometry([openmc.Cell(region=-box)])
+    box = openmc.model.RectangularParallelepiped(*[-10, 10]*3, boundary_type='vacuum')
+    model.geometry = openmc.Geometry([openmc.Cell(region=-box)])
 
-    settings = openmc.Settings()
-    settings.particles = 100
-    settings.batches = 10
-    settings.run_mode = 'fixed source'
+    model.settings.particles = 100
+    model.settings.batches = 10
+    model.settings.run_mode = 'fixed source'
+
+    return model
 
-    model = openmc.Model(geometry=geometry, settings=settings)
+
+@pytest.mark.parametrize('mesh_type', ('rectangular', 'cylindrical'))
+def test_mesh_source_independent(run_in_tmpdir, void_model, mesh_type):
+    """
+    A void model containing a single box
+    """
+    model = void_model
 
     # define a 2 x 2 x 2 mesh
     if mesh_type == 'rectangular':
@@ -310,6 +316,41 @@ def test_mesh_source_independent(run_in_tmpdir, mesh_type):
     assert mesh_source.strength == 1.0
 
 
+@pytest.mark.parametrize("library", ('moab', 'libmesh'))
+def test_umesh_source_independent(run_in_tmpdir, request, void_model, library):
+    import openmc.lib
+    # skip the test if the library is not enabled
+    if library == 'moab' and not openmc.lib._dagmc_enabled():
+        pytest.skip("DAGMC (and MOAB) mesh not enabled in this build.")
+
+    if library == 'libmesh' and not openmc.lib._libmesh_enabled():
+        pytest.skip("LibMesh is not enabled in this build.")
+
+    model = void_model
+
+    mesh_filename = Path(request.fspath).parent / "test_mesh_tets.e"
+    uscd_mesh = openmc.UnstructuredMesh(mesh_filename, library)
+    ind_source = openmc.IndependentSource()
+    n_elements = 12_000
+    model.settings.source = openmc.MeshSource(uscd_mesh, n_elements*[ind_source])
+    model.export_to_model_xml()
+    try:
+        openmc.lib.init()
+        openmc.lib.simulation_init()
+        sites = openmc.lib.sample_external_source(10)
+        openmc.lib.statepoint_write('statepoint.h5')
+    finally:
+        openmc.lib.finalize()
+
+    with openmc.StatePoint('statepoint.h5') as sp:
+        uscd_mesh = sp.meshes[uscd_mesh.id]
+
+    # ensure at least that all sites are inside the mesh
+    bounding_box = uscd_mesh.bounding_box
+    for site in sites:
+        assert site.r in bounding_box
+
+
 def test_mesh_source_file(run_in_tmpdir):
     # Creating a source file with a single particle
     source_particle = openmc.SourceParticle(time=10.0)