Repository: openmc-dev/openmc

Issue:
numpy.floatX * FissionYield causes ufunc errors
tl;dr: ``w * fy == fy * w == fy * numpy.float64(w) != numpy.float64(w) * fy``

When computing the effective fission yields, a [collection of special methods](https://github.com/openmc-dev/openmc/blob/9a3574676125abe131796ab1f9b63702bb84fd16/openmc/deplete/nuclide.py#L535-L567) are provided that allow simple ``fy_collapsed = fy0 * w0 + w1 * f1 ...``. This is great, because one just has to compute the various scalar weights for each energy group, and we can compute a single fission yield that captures the correct spectrum.

However, if the weight is a special `numpy` float like `numpy.float64` __and__ is the first argument, the weighting errors. I've provided a small example to demonstrate 
```python
import numpy
import openmc
import openmc.deplete
print(numpy.__version__)
print(openmc.__version__)

fy = openmc.deplete.FissionYield(("Xe135", "Sm155"), numpy.array((1.0, 1.0)))

print(fy * 0.2)
print(0.2 * fy)
print(fy * numpy.float64(0.2))
try:
    print(numpy.float64(0.2) * fy)
except Exception as ee:
    print(str(ee))

try:
    print(numpy.float32(0.2) * fy)
except Exception as ee:
    print(str(ee))
```
Output
```
1.18.1
0.12.0-dev
<FissionYield containing 2 products and yields>
<FissionYield containing 2 products and yields>
<FissionYield containing 2 products and yields>
ufunc 'multiply' did not contain a loop with signature matching types (dtype('<U32'), dtype('<U32')) -> dtype('<U32')
ufunc 'multiply' did not contain a loop with signature matching types (dtype('<U32'), dtype('<U32')) -> dtype('<U32')
```

Even though ``__rmul__`` is implemented, which should take over for ``w * fy``,  it never gets called. Recent updates to numpy, I think 1.16(?), made it really easy to create an array from an arbitrary sequence. For example,
```python
print(numpy.asarray(fy))
['Xe135' 'Sm155']
```
It looks like numpy sees the left argument of the multiply, our fission yields, can be coerced into an array, and then multiplies that array by the scalar.

There are couple ways that we can fix this, and I'm not sure which is best. The numpy docs provide some instructions on [writing custom containers](https://docs.scipy.org/doc/numpy/user/basics.dispatch.html), or using some mixins [NDArrayOperatorMixin](https://docs.scipy.org/doc/numpy/reference/generated/numpy.lib.mixins.NDArrayOperatorsMixin.html#numpy.lib.mixins.NDArrayOperatorsMixin) that may work here. However, I don't think we want to lose the `Mapping` API, as the `Chain.form_matrix` relies on this 
https://github.com/openmc-dev/openmc/blob/9a3574676125abe131796ab1f9b63702bb84fd16/openmc/deplete/chain.py#L534-L538


Discussion:
Honestly this sounds like a numpy bug to me. The protocol is that if `x.__mul__(y)` doesn't work, it should return `NotImplemented` so that the interpreter will know to try `y.__rmul__(x)`. The reason it doesn't work even though we have `__rmul__` implemented is because `float64.__mul__` is just raising an exception rather than returning NotImplemented.

...after a little digging, found this: numpy/numpy#9677. A solution suggested there is to just add `__array_ufunc__ = None` to the `FissionYield` class.

Resolving patch:
diff --git a/openmc/deplete/nuclide.py b/openmc/deplete/nuclide.py
--- a/openmc/deplete/nuclide.py
+++ b/openmc/deplete/nuclide.py
@@ -533,6 +533,7 @@ def items(self):
         return zip(self.products, self.yields)
 
     def __add__(self, other):
+        """Add one set of fission yields to this set, return new yields"""
         if not isinstance(other, FissionYield):
             return NotImplemented
         new = FissionYield(self.products, self.yields.copy())
@@ -550,12 +551,14 @@ def __radd__(self, other):
         return self + other
 
     def __imul__(self, scalar):
+        """Scale these fission yields by a real scalar"""
         if not isinstance(scalar, Real):
             return NotImplemented
         self.yields *= scalar
         return self
 
     def __mul__(self, scalar):
+        """Return a new set of yields scaled by a real scalar"""
         if not isinstance(scalar, Real):
             return NotImplemented
         new = FissionYield(self.products, self.yields.copy())
@@ -568,3 +571,8 @@ def __rmul__(self, scalar):
     def __repr__(self):
         return "<{} containing {} products and yields>".format(
             self.__class__.__name__, len(self))
+
+    # Avoid greedy numpy operations like np.float64 * fission_yield
+    # converting this to an array on the fly. Force __rmul__ and
+    # __radd__. See issue #1492
+    __array_ufunc__ = None


Test patch:
diff --git a/tests/unit_tests/test_deplete_nuclide.py b/tests/unit_tests/test_deplete_nuclide.py
--- a/tests/unit_tests/test_deplete_nuclide.py
+++ b/tests/unit_tests/test_deplete_nuclide.py
@@ -149,12 +149,18 @@ def test_fission_yield_distribution():
     # __getitem__ return yields as a view into yield matrix
     assert orig_yields.yields.base is yield_dist.yield_matrix
 
-    # Fission yield feature uses scaled and incremented
+    # Scale and increment fission yields
     mod_yields = orig_yields * 2
     assert numpy.array_equal(orig_yields.yields * 2, mod_yields.yields)
     mod_yields += orig_yields
     assert numpy.array_equal(orig_yields.yields * 3, mod_yields.yields)
 
+    mod_yields = 2.0 * orig_yields
+    assert numpy.array_equal(orig_yields.yields * 2, mod_yields.yields)
+
+    mod_yields = numpy.float64(2.0) * orig_yields
+    assert numpy.array_equal(orig_yields.yields * 2, mod_yields.yields)
+
     # Failure modes for adding, multiplying yields
     similar = numpy.empty_like(orig_yields.yields)
     with pytest.raises(TypeError):