Repository: openmc-dev/openmc

Issue:
Cylinder meshes to allow non central location
As mentioned by @paulromano in PR #2233 the ```CylindricalMesh``` is central around a location.

We could allow for an x,y,z offset that defaults to (0,0,0).

Also tagging @RemDelaporteMathurin 


Discussion:

Resolving patch:
diff --git a/openmc/mesh.py b/openmc/mesh.py
--- a/openmc/mesh.py
+++ b/openmc/mesh.py
@@ -1050,6 +1050,9 @@ class CylindricalMesh(StructuredMesh):
         The default value is [0, 2π], i.e. the full phi range.
     z_grid : numpy.ndarray
         1-D array of mesh boundary points along the z-axis.
+    origin : numpy.ndarray
+        1-D array of length 3 the (x,y,z) origin of the mesh in
+        cartesian coordinates
     indices : Iterable of tuple
         An iterable of mesh indices for each mesh element, e.g. [(1, 1, 1),
         (2, 1, 1), ...]
@@ -1062,6 +1065,7 @@ def __init__(self, mesh_id=None, name=''):
         self._r_grid = None
         self._phi_grid = [0.0, 2*pi]
         self._z_grid = None
+        self.origin = (0., 0., 0.)
 
     @property
     def dimension(self):
@@ -1073,6 +1077,10 @@ def dimension(self):
     def n_dimension(self):
         return 3
 
+    @property
+    def origin(self):
+        return self._origin
+
     @property
     def r_grid(self):
         return self._r_grid
@@ -1099,6 +1107,12 @@ def indices(self):
                 for p in range(1, np + 1)
                 for r in range(1, nr + 1))
 
+    @origin.setter
+    def origin(self, coords):
+        cv.check_type('mesh origin', coords, Iterable, Real)
+        cv.check_length("mesh origin", coords, 3)
+        self._origin = np.asarray(coords)
+
     @r_grid.setter
     def r_grid(self, grid):
         cv.check_type('mesh r_grid', grid, Iterable, Real)
@@ -1118,6 +1132,7 @@ def __repr__(self):
         fmt = '{0: <16}{1}{2}\n'
         string = super().__repr__()
         string += fmt.format('\tDimensions', '=\t', self.n_dimension)
+        string += fmt.format('\tOrigin', '=\t', self.origin)
         r_grid_str = str(self._r_grid) if self._r_grid is None else len(self._r_grid)
         string += fmt.format('\tN R pnts:', '=\t', r_grid_str)
         if self._r_grid is not None:
@@ -1144,6 +1159,8 @@ def from_hdf5(cls, group):
         mesh.r_grid = group['r_grid'][()]
         mesh.phi_grid = group['phi_grid'][()]
         mesh.z_grid = group['z_grid'][()]
+        if 'origin' in group:
+            mesh.origin = group['origin'][()]
 
         return mesh
 
@@ -1240,6 +1257,9 @@ def to_xml_element(self):
         subelement = ET.SubElement(element, "z_grid")
         subelement.text = ' '.join(map(str, self.z_grid))
 
+        subelement = ET.SubElement(element, "origin")
+        subelement.text = ' '.join(map(str, self.origin))
+
         return element
 
     @classmethod
@@ -1263,6 +1283,8 @@ def from_xml_element(cls, elem):
         mesh.r_grid = [float(x) for x in get_text(elem, "r_grid").split()]
         mesh.phi_grid = [float(x) for x in get_text(elem, "phi_grid").split()]
         mesh.z_grid = [float(x) for x in get_text(elem, "z_grid").split()]
+        mesh.origin = [float(x) for x in get_text(elem, "origin", default=[0., 0., 0.]).split()]
+
         return mesh
 
     @property
@@ -1304,12 +1326,13 @@ def write_data_to_vtk(self, filename, datasets, volume_normalization=True):
         """
         # create points
         pts_cylindrical = self.vertices.T.reshape(-1, 3)
-
         pts_cartesian = np.copy(pts_cylindrical)
-        r, phi = pts_cylindrical[:, 0], pts_cylindrical[:, 1]
-        pts_cartesian[:, 0] = r * np.cos(phi)
-        pts_cartesian[:, 1] = r * np.sin(phi)
 
+        r, phi = pts_cylindrical[:, 0], pts_cylindrical[:, 1]
+        pts_cartesian[:, 0] = r * np.cos(phi) + self.origin[0]
+        pts_cartesian[:, 1] = r * np.sin(phi) + self.origin[1]
+        pts_cartesian[:, 2] += self.origin[2]
+        
         return super().write_data_to_vtk(
             points=pts_cartesian,
             filename=filename,
@@ -1346,6 +1369,9 @@ class SphericalMesh(StructuredMesh):
     phi_grid : numpy.ndarray
         1-D array of mesh boundary points along the phi-axis in radians.
         The default value is [0, 2π], i.e. the full phi range.
+    origin : numpy.ndarray
+        1-D array of length 3 the (x,y,z) origin of the mesh in
+        cartesian coordinates
     indices : Iterable of tuple
         An iterable of mesh indices for each mesh element, e.g. [(1, 1, 1),
         (2, 1, 1), ...]
@@ -1358,6 +1384,7 @@ def __init__(self, mesh_id=None, name=''):
         self._r_grid = None
         self._theta_grid = [0, pi]
         self._phi_grid = [0, 2*pi]
+        self.origin = (0., 0., 0.)
 
     @property
     def dimension(self):
@@ -1369,6 +1396,10 @@ def dimension(self):
     def n_dimension(self):
         return 3
 
+    @property
+    def origin(self):
+        return self._origin
+
     @property
     def r_grid(self):
         return self._r_grid
@@ -1395,6 +1426,12 @@ def indices(self):
                 for t in range(1, nt + 1)
                 for r in range(1, nr + 1))
 
+    @origin.setter
+    def origin(self, coords):
+        cv.check_type('mesh origin', coords, Iterable, Real)
+        cv.check_length("mesh origin", coords, 3)
+        self._origin = np.asarray(coords)
+
     @r_grid.setter
     def r_grid(self, grid):
         cv.check_type('mesh r_grid', grid, Iterable, Real)
@@ -1414,6 +1451,7 @@ def __repr__(self):
         fmt = '{0: <16}{1}{2}\n'
         string = super().__repr__()
         string += fmt.format('\tDimensions', '=\t', self.n_dimension)
+        string += fmt.format('\tOrigin', '=\t', self.origin)
         r_grid_str = str(self._r_grid) if self._r_grid is None else len(self._r_grid)
         string += fmt.format('\tN R pnts:', '=\t', r_grid_str)
         if self._r_grid is not None:
@@ -1440,6 +1478,8 @@ def from_hdf5(cls, group):
         mesh.r_grid = group['r_grid'][()]
         mesh.theta_grid = group['theta_grid'][()]
         mesh.phi_grid = group['phi_grid'][()]
+        if 'origin' in group:
+            mesh.origin = group['origin'][()]
 
         return mesh
 
@@ -1466,6 +1506,9 @@ def to_xml_element(self):
         subelement = ET.SubElement(element, "phi_grid")
         subelement.text = ' '.join(map(str, self.phi_grid))
 
+        subelement = ET.SubElement(element, "origin")
+        subelement.text = ' '.join(map(str, self.origin))
+
         return element
 
     @classmethod
@@ -1489,6 +1532,8 @@ def from_xml_element(cls, elem):
         mesh.r_grid = [float(x) for x in get_text(elem, "r_grid").split()]
         mesh.theta_grid = [float(x) for x in get_text(elem, "theta_grid").split()]
         mesh.phi_grid = [float(x) for x in get_text(elem, "phi_grid").split()]
+        mesh.origin = [float(x) for x in get_text(elem, "origin", default=[0., 0., 0.]).split()]
+
         return mesh
 
     @property
@@ -1530,12 +1575,13 @@ def write_data_to_vtk(self, filename, datasets, volume_normalization=True):
         """
         # create points
         pts_spherical = self.vertices.T.reshape(-1, 3)
-
         pts_cartesian = np.copy(pts_spherical)
+
         r, theta, phi = pts_spherical[:, 0], pts_spherical[:, 1], pts_spherical[:, 2]
-        pts_cartesian[:, 0] = r * np.sin(theta) * np.cos(phi)
-        pts_cartesian[:, 1] = r * np.sin(theta) * np.sin(phi)
-        pts_cartesian[:, 2] = r * np.cos(theta)
+
+        pts_cartesian[:, 0] = r * np.sin(phi) * np.cos(theta) + self.origin[0]
+        pts_cartesian[:, 1] = r * np.sin(phi) * np.sin(theta) + self.origin[1]
+        pts_cartesian[:, 2] = r * np.cos(phi) + self.origin[2]
 
         return super().write_data_to_vtk(
             points=pts_cartesian,


Test patch:
diff --git a/tests/regression_tests/filter_mesh/inputs_true.dat b/tests/regression_tests/filter_mesh/inputs_true.dat
--- a/tests/regression_tests/filter_mesh/inputs_true.dat
+++ b/tests/regression_tests/filter_mesh/inputs_true.dat
@@ -57,11 +57,13 @@
     <r_grid>0.0 0.4411764705882353 0.8823529411764706 1.3235294117647058 1.7647058823529411 2.2058823529411766 2.6470588235294117 3.0882352941176467 3.5294117647058822 3.9705882352941178 4.411764705882353 4.852941176470588 5.294117647058823 5.735294117647059 6.1764705882352935 6.617647058823529 7.0588235294117645 7.5</r_grid>
     <phi_grid>0.0 0.3490658503988659 0.6981317007977318 1.0471975511965976 1.3962634015954636 1.7453292519943295 2.0943951023931953 2.443460952792061 2.792526803190927 3.141592653589793 3.490658503988659 3.839724354387525 4.1887902047863905 4.537856055185257 4.886921905584122 5.235987755982989 5.585053606381854 5.93411945678072 6.283185307179586</phi_grid>
     <z_grid>-7.5 -6.5625 -5.625 -4.6875 -3.75 -2.8125 -1.875 -0.9375 0.0 0.9375 1.875 2.8125 3.75 4.6875 5.625 6.5625 7.5</z_grid>
+    <origin>0.0 0.0 0.0</origin>
   </mesh>
   <mesh id="6" type="spherical">
     <r_grid>0.0 0.4411764705882353 0.8823529411764706 1.3235294117647058 1.7647058823529411 2.2058823529411766 2.6470588235294117 3.0882352941176467 3.5294117647058822 3.9705882352941178 4.411764705882353 4.852941176470588 5.294117647058823 5.735294117647059 6.1764705882352935 6.617647058823529 7.0588235294117645 7.5</r_grid>
     <theta_grid>0.0 0.39269908169872414 0.7853981633974483 1.1780972450961724 1.5707963267948966 1.9634954084936207 2.356194490192345 2.748893571891069 3.141592653589793</theta_grid>
     <phi_grid>0.0 0.3490658503988659 0.6981317007977318 1.0471975511965976 1.3962634015954636 1.7453292519943295 2.0943951023931953 2.443460952792061 2.792526803190927 3.141592653589793 3.490658503988659 3.839724354387525 4.1887902047863905 4.537856055185257 4.886921905584122 5.235987755982989 5.585053606381854 5.93411945678072 6.283185307179586</phi_grid>
+    <origin>0.0 0.0 0.0</origin>
   </mesh>
   <filter id="1" type="mesh">
     <bins>1</bins>
diff --git a/tests/unit_tests/test_cylindrical_mesh.py b/tests/unit_tests/test_cylindrical_mesh.py
new file mode 100644
--- /dev/null
+++ b/tests/unit_tests/test_cylindrical_mesh.py
@@ -0,0 +1,105 @@
+from itertools import product, permutations
+
+import openmc
+import numpy as np
+
+import pytest
+
+geom_size = 5
+
+@pytest.fixture()
+def model():
+    openmc.reset_auto_ids()
+
+    water = openmc.Material(name='water')
+    water.add_element('H', 2.0)
+    water.add_element('O', 1.0)
+    water.set_density('g/cc', 1.0)
+
+    rpp = openmc.model.RectangularParallelepiped(*([-geom_size, geom_size] * 3),
+                                                 boundary_type='vacuum')
+
+    cell = openmc.Cell(region=-rpp, fill=water)
+
+    geom = openmc.Geometry([cell])
+
+    source = openmc.Source()
+    source.space = openmc.stats.Point()
+    source.energy = openmc.stats.Discrete([10000], [1.0])
+
+    settings = openmc.Settings()
+    settings.particles = 2000
+    settings.batches = 10
+    settings.run_mode = 'fixed source'
+
+    # build
+    mesh = openmc.CylindricalMesh()
+    mesh.phi_grid = np.linspace(0, 2*np.pi, 21)
+    mesh.z_grid = np.linspace(-geom_size, geom_size, 11)
+    mesh.r_grid = np.linspace(0, geom_size, geom_size)
+
+    tally = openmc.Tally()
+
+    mesh_filter = openmc.MeshFilter(mesh)
+    tally.filters.append(mesh_filter)
+
+    tally.scores.append("flux")
+
+    tallies = openmc.Tallies([tally])
+
+    return openmc.Model(geometry=geom, settings=settings, tallies=tallies)
+
+def test_origin_read_write_to_xml(run_in_tmpdir, model):
+    """Tests that the origin attribute can be written and read back to XML
+    """
+    mesh = model.tallies[0].filters[0].mesh
+    mesh.origin = [0.1, 0.2, 0.3]
+    model.tallies.export_to_xml()
+
+    # read back
+    new_tallies = openmc.Tallies.from_xml()
+    new_tally = new_tallies[0]
+    new_mesh = new_tally.filters[0].mesh
+    np.testing.assert_equal(new_mesh.origin, mesh.origin)
+
+estimators = ('tracklength', 'collision')
+origins = set(permutations((-geom_size, 0, 0)))
+origins |= set(permutations((geom_size, 0, 0)))
+
+test_cases = product(estimators, origins)
+
+def label(p):
+    if isinstance(p, tuple):
+        return f'origin:{p}'
+    if isinstance(p, str):
+        return f'estimator:{p}'
+
+@pytest.mark.parametrize('estimator,origin', test_cases, ids=label)
+def test_offset_mesh(model, estimator, origin):
+    """Tests that the mesh has been moved based on tally results
+    """
+    mesh = model.tallies[0].filters[0].mesh
+    model.tallies[0].estimator = estimator
+    # move the center of the cylinder mesh upwards
+    mesh.origin = origin
+
+    sp_filename = model.run()
+
+    with openmc.StatePoint(sp_filename) as sp:
+        tally = sp.tallies[1]
+
+        # we've translated half of the cylinder mesh above the model,
+        # so ensure that half of the bins are populated
+        assert np.count_nonzero(tally.mean) == tally.mean.size / 2
+
+        # check that the half of the mesh that is outside of the geometry
+        # contains the zero values
+        mean = tally.get_reshaped_data('mean', expand_dims=True)
+        centroids = mesh.centroids
+        for ijk in mesh.indices:
+            i, j, k = np.array(ijk) - 1
+            print(centroids[:, i, j, k])
+            if model.geometry.find(centroids[:, i, j, k]):
+                mean[i, j, k] == 0.0
+            else:
+                mean[i, j, k] != 0.0
diff --git a/tests/unit_tests/test_deplete_activation.py b/tests/unit_tests/test_deplete_activation.py
--- a/tests/unit_tests/test_deplete_activation.py
+++ b/tests/unit_tests/test_deplete_activation.py
@@ -33,7 +33,7 @@ def model():
     )
     model.settings.run_mode = 'fixed source'
 
-    rx_tally = openmc.Tally()
+    rx_tally = openmc.Tally(name='activation tally')
     rx_tally.scores = ['(n,gamma)']
     model.tallies.append(rx_tally)
 
@@ -53,7 +53,7 @@ def test_activation(run_in_tmpdir, model, reaction_rate_mode, reaction_rate_opts
     # Determine (n.gamma) reaction rate using initial run
     sp = model.run()
     with openmc.StatePoint(sp) as sp:
-        tally = sp.tallies[1]
+        tally = sp.get_tally(name='activation tally')
         capture_rate = tally.mean.flat[0]
 
     # Create one-nuclide depletion chain
diff --git a/tests/unit_tests/test_spherical_mesh.py b/tests/unit_tests/test_spherical_mesh.py
new file mode 100644
--- /dev/null
+++ b/tests/unit_tests/test_spherical_mesh.py
@@ -0,0 +1,109 @@
+from itertools import product, permutations
+
+import openmc
+import numpy as np
+
+import pytest
+
+geom_size = 5
+
+@pytest.fixture()
+def model():
+    openmc.reset_auto_ids()
+
+    water = openmc.Material(name='water')
+    water.add_element('H', 2.0)
+    water.add_element('O', 1.0)
+    water.set_density('g/cc', 1.0)
+
+    rpp = openmc.model.RectangularParallelepiped(*([-geom_size, geom_size] * 3),
+                                                 boundary_type='vacuum')
+
+    cell = openmc.Cell(region=-rpp, fill=water)
+
+    geom = openmc.Geometry([cell])
+
+    source = openmc.Source()
+    source.space = openmc.stats.Point()
+    source.energy = openmc.stats.Discrete([10000], [1.0])
+
+    settings = openmc.Settings()
+    settings.particles = 2000
+    settings.batches = 10
+    settings.run_mode = 'fixed source'
+
+    # build
+    mesh = openmc.SphericalMesh()
+    mesh.phi_grid = np.linspace(0, 2*np.pi, 13)
+    mesh.theta_grid = np.linspace(0, np.pi, 7)
+    mesh.r_grid = np.linspace(0, geom_size, geom_size)
+
+    tally = openmc.Tally()
+
+    mesh_filter = openmc.MeshFilter(mesh)
+    tally.filters.append(mesh_filter)
+
+    tally.scores.append("flux")
+
+    tallies = openmc.Tallies([tally])
+
+    return openmc.Model(geometry=geom, settings=settings, tallies=tallies)
+
+def test_origin_read_write_to_xml(run_in_tmpdir, model):
+    """Tests that the origin attribute can be written and read back to XML
+    """
+    mesh = model.tallies[0].filters[0].mesh
+    mesh.origin = [0.1, 0.2, 0.3]
+    model.tallies.export_to_xml()
+
+    # read back
+    new_tallies = openmc.Tallies.from_xml()
+    new_tally = new_tallies[0]
+    new_mesh = new_tally.filters[0].mesh
+    np.testing.assert_equal(new_mesh.origin, mesh.origin)
+
+estimators = ('tracklength', 'collision')
+# TODO: determine why this is needed for spherical mesh
+# but not cylindrical mesh
+offset = geom_size + 0.001
+
+origins = set(permutations((-offset, 0, 0)))
+origins |= set(permutations((offset, 0, 0)))
+
+test_cases = product(estimators, origins)
+
+def label(p):
+    if isinstance(p, tuple):
+        return f'origin:{p}'
+    if isinstance(p, str):
+        return f'estimator:{p}'
+
+@pytest.mark.parametrize('estimator,origin', test_cases, ids=label)
+def test_offset_mesh(model, estimator, origin):
+    """Tests that the mesh has been moved based on tally results
+    """
+    mesh = model.tallies[0].filters[0].mesh
+    model.tallies[0].estimator = estimator
+    # move the center of the spherical mesh
+    mesh.origin = origin
+
+    sp_filename = model.run()
+
+    with openmc.StatePoint(sp_filename) as sp:
+        tally = sp.tallies[1]
+
+        # we've translated half of the spherical mesh above the model,
+        # so ensure that half of the bins are populated
+        assert np.count_nonzero(tally.mean) == tally.mean.size / 2
+
+        # check that the half of the mesh that is outside of the geometry
+        # contains the zero values
+        mean = tally.get_reshaped_data('mean', expand_dims=True)
+        centroids = mesh.centroids
+        for ijk in mesh.indices:
+            i, j, k = np.array(ijk) - 1
+            print(centroids[:, i, j, k])
+            if model.geometry.find(centroids[:, i, j, k]):
+                mean[i, j, k] == 0.0
+            else:
+                mean[i, j, k] != 0.0