Repository: openmc-dev/openmc

Issue:
Error when calling decay_photon_energy on stable isotope
When calling ```decay_photon_energy``` on a material which has no gamma decays the code raises an indexError

in this case ```dists``` is ```[]``` so ```dists[0]``` is not possible

Perhaps we should add some error handling for empty lists to ```combine_distributions```

Code to reproduce
```python
import openmc
openmc.config['chain_file']='chain-endf.xml'
a=openmc.Material()
a.add_nuclide('Li6',1)
a.volume=1
a.decay_photon_energy
```
error message
```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/openmc/openmc/material.py", line 276, in decay_photon_energy
    return openmc.data.combine_distributions(dists, probs)
  File "/home/openmc/openmc/stats/univariate.py", line 1252, in combine_distributions
    return dist_list[0]
IndexError: list index out of range
```


Comments:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 050c8e287..b7c548a37 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,9 +34,6 @@ jobs:
         vectfit: [n]
 
         include:
-          - python-version: 3.6
-            omp: n
-            mpi: n
           - python-version: 3.7
             omp: n
             mpi: n
diff --git a/docs/source/devguide/styleguide.rst b/docs/source/devguide/styleguide.rst
index 44d80915e..2a744b819 100644
--- a/docs/source/devguide/styleguide.rst
+++ b/docs/source/devguide/styleguide.rst
@@ -142,7 +142,7 @@ Style for Python code should follow PEP8_.
 
 Docstrings for functions and methods should follow numpydoc_ style.
 
-Python code should work with Python 3.6+.
+Python code should work with Python 3.7+.
 
 Use of third-party Python packages should be limited to numpy_, scipy_,
 matplotlib_, pandas_, and h5py_. Use of other third-party packages must be
diff --git a/docs/source/usersguide/install.rst b/docs/source/usersguide/install.rst
index b57958814..74be9ba0b 100644
--- a/docs/source/usersguide/install.rst
+++ b/docs/source/usersguide/install.rst
@@ -511,7 +511,7 @@ to install the Python package in :ref:`"editable" mode <devguide_editable>`.
 Prerequisites
 -------------
 
-The Python API works with Python 3.6+. In addition to Python itself, the API
+The Python API works with Python 3.7+. In addition to Python itself, the API
 relies on a number of third-party packages. All prerequisites can be installed
 using Conda_ (recommended), pip_, or through the package manager in most Linux
 distributions.
diff --git a/openmc/data/decay.py b/openmc/data/decay.py
index d7ededf33..801ef4410 100644
--- a/openmc/data/decay.py
+++ b/openmc/data/decay.py
@@ -1,10 +1,9 @@
 from collections.abc import Iterable
 from io import StringIO
 from math import log
-from pathlib import Path
 import re
+from typing import Optional
 from warnings import warn
-from xml.etree import ElementTree as ET
 
 import numpy as np
 from uncertainties import ufloat, UFloat
@@ -579,7 +578,7 @@ class Decay(EqualityMixin):
 _DECAY_PHOTON_ENERGY = {}
 
 
-def decay_photon_energy(nuclide: str) -> Univariate:
+def decay_photon_energy(nuclide: str) -> Optional[Univariate]:
     """Get photon energy distribution resulting from the decay of a nuclide
 
     This function relies on data stored in a depletion chain. Before calling it
@@ -595,9 +594,10 @@ def decay_photon_energy(nuclide: str) -> Univariate:
 
     Returns
     -------
-    openmc.stats.Univariate
-        Distribution of energies in [eV] of photons emitted from decay. Note
-        that the probabilities represent intensities, given as [decay/sec].
+    openmc.stats.Univariate or None
+        Distribution of energies in [eV] of photons emitted from decay, or None
+        if no photon source exists. Note that the probabilities represent
+        intensities, given as [decay/sec].
     """
     if not _DECAY_PHOTON_ENERGY:
         chain_file = openmc.config.get('chain_file')
diff --git a/openmc/material.py b/openmc/material.py
index 16b5a3048..e99eec52c 100644
--- a/openmc/material.py
+++ b/openmc/material.py
@@ -92,10 +92,11 @@ class Material(IDManagerMixin):
     fissionable_mass : float
         Mass of fissionable nuclides in the material in [g]. Requires that the
         :attr:`volume` attribute is set.
-    decay_photon_energy : openmc.stats.Univariate
+    decay_photon_energy : openmc.stats.Univariate or None
         Energy distribution of photons emitted from decay of unstable nuclides
-        within the material. The integral of this distribution is the total
-        intensity of the photon source in [decay/sec].
+        within the material, or None if no photon source exists. The integral of
+        this distribution is the total intensity of the photon source in
+        [decay/sec].
 
         .. versionadded:: 0.14.0
 
@@ -264,7 +265,7 @@ class Material(IDManagerMixin):
         return density*self.volume
 
     @property
-    def decay_photon_energy(self) -> Univariate:
+    def decay_photon_energy(self) -> Optional[Univariate]:
         atoms = self.get_nuclide_atoms()
         dists = []
         probs = []
@@ -273,7 +274,7 @@ class Material(IDManagerMixin):
             if source_per_atom is not None:
                 dists.append(source_per_atom)
                 probs.append(num_atoms)
-        return openmc.data.combine_distributions(dists, probs)
+        return openmc.data.combine_distributions(dists, probs) if dists else None
 
     @classmethod
     def from_hdf5(cls, group: h5py.Group):
diff --git a/openmc/mesh.py b/openmc/mesh.py
index a4c50a608..d4db5011b 100644
--- a/openmc/mesh.py
+++ b/openmc/mesh.py
@@ -514,7 +514,7 @@ class RegularMesh(StructuredMesh):
     def from_domain(
         cls,
         domain,
-        dimension=[100, 100, 100],
+        dimension=(10, 10, 10),
         mesh_id=None,
         name=''
     ):
@@ -1137,6 +1137,76 @@ class CylindricalMesh(StructuredMesh):
 
         return mesh
 
+    @classmethod
+    def from_domain(
+        cls,
+        domain,
+        dimension=(10, 10, 10),
+        mesh_id=None,
+        phi_grid_bounds=(0.0, 2*pi),
+        name=''
+    ):
+        """Creates a regular CylindricalMesh from an existing openmc domain.
+
+        Parameters
+        ----------
+        domain : openmc.Cell or openmc.Region or openmc.Universe or openmc.Geometry
+            The object passed in will be used as a template for this mesh. The
+            bounding box of the property of the object passed will be used to
+            set the r_grid, z_grid ranges.
+        dimension : Iterable of int
+            The number of equally spaced mesh cells in each direction (r_grid,
+            phi_grid, z_grid)
+        mesh_id : int
+            Unique identifier for the mesh
+        phi_grid_bounds : numpy.ndarray
+            Mesh bounds points along the phi-axis in radians. The default value
+            is (0, 2π), i.e., the full phi range.
+        name : str
+            Name of the mesh
+
+        Returns
+        -------
+        openmc.RegularMesh
+            RegularMesh instance
+
+        """
+        cv.check_type(
+            "domain",
+            domain,
+            (openmc.Cell, openmc.Region, openmc.Universe, openmc.Geometry),
+        )
+
+        mesh = cls(mesh_id, name)
+
+        # loaded once to avoid reading h5m file repeatedly
+        cached_bb = domain.bounding_box
+        max_bounding_box_radius = max(
+            [
+                cached_bb[0][0],
+                cached_bb[0][1],
+                cached_bb[1][0],
+                cached_bb[1][1],
+            ]
+        )
+        mesh.r_grid = np.linspace(
+            0,
+            max_bounding_box_radius,
+            num=dimension[0]+1
+        )
+        mesh.phi_grid = np.linspace(
+            phi_grid_bounds[0],
+            phi_grid_bounds[1],
+            num=dimension[1]+1
+        )
+        mesh.z_grid = np.linspace(
+            cached_bb[0][2],
+            cached_bb[1][2],
+            num=dimension[2]+1
+        )
+
+        return mesh
+
     def to_xml_element(self):
         """Return XML representation of the mesh
 
diff --git a/setup.py b/setup.py
index 477f29dec..a4b77e68b 100755
--- a/setup.py
+++ b/setup.py
@@ -5,11 +5,7 @@ import sys
 import numpy as np
 
 from setuptools import setup, find_packages
-try:
-    from Cython.Build import cythonize
-    have_cython = True
-except ImportError:
-    have_cython = False
+from Cython.Build import cythonize
 
 
 # Determine shared library suffix
@@ -57,14 +53,14 @@ kwargs = {
         'Topic :: Scientific/Engineering'
         'Programming Language :: C++',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.6',
+        'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
         'Programming Language :: Python :: 3.9',
     ],
 
     # Dependencies
-    'python_requires': '>=3.6',
+    'python_requires': '>=3.7',
     'install_requires': [
         'numpy>=1.9', 'h5py', 'scipy', 'ipython', 'matplotlib',
         'pandas', 'lxml', 'uncertainties'
@@ -76,13 +72,9 @@ kwargs = {
         'test': ['pytest', 'pytest-cov', 'colorama'],
         'vtk': ['vtk'],
     },
+    # Cython is used to add resonance reconstruction and fast float_endf
+    'ext_modules': cythonize('openmc/data/*.pyx'),
+    'include_dirs': [np.get_include()]
 }
 
-# If Cython is present, add resonance reconstruction and fast float_endf
-if have_cython:
-    kwargs.update({
-        'ext_modules': cythonize('openmc/data/*.pyx'),
-        'include_dirs': [np.get_include()]
-    })
-
 setup(**kwargs)


Test patch:
diff --git a/tests/unit_tests/test_material.py b/tests/unit_tests/test_material.py
index 58df246dd..80935d68d 100644
--- a/tests/unit_tests/test_material.py
+++ b/tests/unit_tests/test_material.py
@@ -573,3 +573,9 @@ def test_decay_photon_energy():
     assert src.integral() == pytest.approx(sum(
         intensity(decay_photon_energy(nuc)) for nuc in m.get_nuclides()
     ))
+
+    # A material with no unstable nuclides should have no decay photon source
+    stable = openmc.Material()
+    stable.add_nuclide('Gd156', 1.0)
+    stable.volume = 1.0
+    assert stable.decay_photon_energy is None
diff --git a/tests/unit_tests/test_mesh_from_domain.py b/tests/unit_tests/test_mesh_from_domain.py
index ce27288ad..b4edae196 100644
--- a/tests/unit_tests/test_mesh_from_domain.py
+++ b/tests/unit_tests/test_mesh_from_domain.py
@@ -16,6 +16,22 @@ def test_reg_mesh_from_cell():
     assert np.array_equal(mesh.upper_right, cell.bounding_box[1])
 
 
+def test_cylindrical_mesh_from_cell():
+    """Tests a CylindricalMesh can be made from a Cell and the specified
+    dimensions are propagated through. Cell is not centralized"""
+    cy_surface = openmc.ZCylinder(r=50)
+    z_surface_1 = openmc.ZPlane(z0=30)
+    z_surface_2 = openmc.ZPlane(z0=0)
+    cell = openmc.Cell(region=-cy_surface & -z_surface_1 & +z_surface_2)
+    mesh = openmc.CylindricalMesh.from_domain(cell, dimension=[2, 4, 3])
+
+    assert isinstance(mesh, openmc.CylindricalMesh)
+    assert np.array_equal(mesh.dimension, (2, 4, 3))
+    assert np.array_equal(mesh.r_grid, [0., 25., 50.])
+    assert np.array_equal(mesh.phi_grid, [0., 0.5*np.pi, np.pi, 1.5*np.pi, 2.*np.pi])
+    assert np.array_equal(mesh.z_grid, [0., 10., 20., 30.])
+
+
 def test_reg_mesh_from_region():
     """Tests a RegularMesh can be made from a Region and the default dimensions
     are propagated through. Region is not centralized"""
@@ -24,28 +40,48 @@ def test_reg_mesh_from_region():
 
     mesh = openmc.RegularMesh.from_domain(region)
     assert isinstance(mesh, openmc.RegularMesh)
-    assert np.array_equal(mesh.dimension, (100, 100, 100))  # default values
+    assert np.array_equal(mesh.dimension, (10, 10, 10))  # default values
     assert np.array_equal(mesh.lower_left, region.bounding_box[0])
     assert np.array_equal(mesh.upper_right, region.bounding_box[1])
 
 
+def test_cylindrical_mesh_from_region():
+    """Tests a CylindricalMesh can be made from a Region and the specified
+    dimensions and phi_grid_bounds are propagated through. Cell is centralized"""
+    cy_surface = openmc.ZCylinder(r=6)
+    z_surface_1 = openmc.ZPlane(z0=30)
+    z_surface_2 = openmc.ZPlane(z0=-30)
+    cell = openmc.Cell(region=-cy_surface & -z_surface_1 & +z_surface_2)
+    mesh = openmc.CylindricalMesh.from_domain(
+        cell,
+        dimension=(6, 2, 3),
+        phi_grid_bounds=(0., np.pi)
+    )
+
+    assert isinstance(mesh, openmc.CylindricalMesh)
+    assert np.array_equal(mesh.dimension, (6, 2, 3))
+    assert np.array_equal(mesh.r_grid, [0., 1., 2., 3., 4., 5., 6.])
+    assert np.array_equal(mesh.phi_grid, [0., 0.5*np.pi, np.pi])
+    assert np.array_equal(mesh.z_grid, [-30., -10., 10., 30.])
+
+
 def test_reg_mesh_from_universe():
-    """Tests a RegularMesh can be made from a Universe and the default dimensions
-    are propagated through. Universe is centralized"""
+    """Tests a RegularMesh can be made from a Universe and the default
+    dimensions are propagated through. Universe is centralized"""
     surface = openmc.Sphere(r=42)
     cell = openmc.Cell(region=-surface)
     universe = openmc.Universe(cells=[cell])
 
     mesh = openmc.RegularMesh.from_domain(universe)
     assert isinstance(mesh, openmc.RegularMesh)
-    assert np.array_equal(mesh.dimension, (100, 100, 100))  # default values
+    assert np.array_equal(mesh.dimension, (10, 10, 10))  # default values
     assert np.array_equal(mesh.lower_left, universe.bounding_box[0])
     assert np.array_equal(mesh.upper_right, universe.bounding_box[1])
 
 
 def test_reg_mesh_from_geometry():
-    """Tests a RegularMesh can be made from a Geometry and the default dimensions
-    are propagated through. Geometry is centralized"""
+    """Tests a RegularMesh can be made from a Geometry and the default
+    dimensions are propagated through. Geometry is centralized"""
     surface = openmc.Sphere(r=42)
     cell = openmc.Cell(region=-surface)
     universe = openmc.Universe(cells=[cell])
@@ -53,7 +89,7 @@ def test_reg_mesh_from_geometry():
 
     mesh = openmc.RegularMesh.from_domain(geometry)
     assert isinstance(mesh, openmc.RegularMesh)
-    assert np.array_equal(mesh.dimension, (100, 100, 100))  # default values
+    assert np.array_equal(mesh.dimension, (10, 10, 10))  # default values
     assert np.array_equal(mesh.lower_left, geometry.bounding_box[0])
     assert np.array_equal(mesh.upper_right, geometry.bounding_box[1])