Repository: openmc-dev/openmc

Issue:
Units on division of scores with tally arithmetic
When two scores are divided that have the same units, currently you get a derived score with the same units as the two scores, e.g. if you have an absorption score divided by another absorption score, the resulting quantity will be shown with units of absorption rather than absorption / absorption. This can be seen in Out[27] in the [tally arithmetic notebook](http://openmc.readthedocs.org/en/latest/pythonapi/examples/tally-arithmetic.html). This would be the correct behavior for addition and subtraction but for multiplication and division (and I suppose for exponentiation), having the same units doesn't mean the derived score should have the same units. As I understand, this should be a fairly trivial fix with @samuelshaner's new hybrid_product capability.



Discussion:
Each value in a Tally will have "units" that describe the scores, nuclides, and filters used to produce the value. Currently, the units of a derived tally will depend on type of product performed, entrywise or tensor, over the scores, nuclides, and filters:
- **Entrywise Product**: An entrywise product is only performed on values that have the same units. For instances, cell [27] of tally-arithmetic.ipynb performs an entrywise product across scores, nuclides, and filters. The units from an entrywise product will always be the units of the common score/nuclide/filter. 
- **Tensor Product**: A tensor product is performed on values irregardless of units. For instances, cell [30] of tally-arithmetic.ipynb performs a tensor product across scores and an entrywise product across nuclides and filters. The units from a tensor product will be `(units left) binary_op (units right)`.

As @paulromano alluded to, it would be better if the units took into account the operation being performed instead of the type of product, so:
- **\+ or -**: If the units of the operands are the same, the units of the derived tally are the common units. If the units are different, the units of the derived tally are `(units left) binary_op (units right)`.
- *** or /**: The units of the derived tally are `(units left) binary_op (units right)`.
- **^**: The units of the derived tally are `(units left) binary_op (exponent)`.

I'll submit a PR with these changes sometime this week.


Resolving patch:
diff --git a/openmc/tallies.py b/openmc/tallies.py
--- a/openmc/tallies.py
+++ b/openmc/tallies.py
@@ -4,7 +4,7 @@
 import copy
 import re
 from functools import partial
-import itertools
+from itertools import product
 from numbers import Integral, Real
 import warnings
 from xml.etree import ElementTree as ET
@@ -1329,7 +1329,7 @@ def get_filter_indices(self, filters=[], filter_bins=[]):
                     if isinstance(self_filter, openmc.MeshFilter):
                         dimension = self_filter.mesh.dimension
                         xyz = [range(1, x+1) for x in dimension]
-                        bins = list(itertools.product(*xyz))
+                        bins = list(product(*xyz))
 
                     # Create list of 2-tuples for energy boundary bins
                     elif isinstance(self_filter, (openmc.EnergyFilter,
@@ -1364,7 +1364,7 @@ def get_filter_indices(self, filters=[], filter_bins=[]):
                     indices *= self_filter.num_bins
 
             # Apply outer product sum between all filter bin indices
-            filter_indices = list(map(sum, itertools.product(*filter_indices)))
+            filter_indices = list(map(sum, product(*filter_indices)))
 
         # If user did not specify any specific Filters, use them all
         else:
@@ -1880,7 +1880,7 @@ def hybrid_product(self, other, binary_op, filter_product=None,
                 new_tally.filters.append(self_filter)
         else:
             all_filters = [self_copy.filters, other_copy.filters]
-            for self_filter, other_filter in itertools.product(*all_filters):
+            for self_filter, other_filter in product(*all_filters):
                 new_filter = openmc.CrossFilter(self_filter, other_filter,
                                                 binary_op)
                 new_tally.filters.append(new_filter)
@@ -1891,20 +1891,31 @@ def hybrid_product(self, other, binary_op, filter_product=None,
                 new_tally.nuclides.append(self_nuclide)
         else:
             all_nuclides = [self_copy.nuclides, other_copy.nuclides]
-            for self_nuclide, other_nuclide in itertools.product(*all_nuclides):
-                new_nuclide = \
-                    openmc.CrossNuclide(self_nuclide, other_nuclide, binary_op)
+            for self_nuclide, other_nuclide in product(*all_nuclides):
+                new_nuclide = openmc.CrossNuclide(self_nuclide, other_nuclide,
+                                                  binary_op)
                 new_tally.nuclides.append(new_nuclide)
 
+        # Define helper function that handles score units appropriately
+        # depending on the binary operator
+        def cross_score(score1, score2, binary_op):
+            if binary_op == '+' or binary_op == '-':
+                if score1 == score2:
+                    return score1
+                else:
+                    return openmc.CrossScore(score1, score2, binary_op)
+            else:
+                return openmc.CrossScore(score1, score2, binary_op)
+
         # Add scores to the new tally
         if score_product == 'entrywise':
             for self_score in self_copy.scores:
-                new_tally.scores.append(self_score)
+                new_score = cross_score(self_score, self_score, binary_op)
+                new_tally.scores.append(new_score)
         else:
             all_scores = [self_copy.scores, other_copy.scores]
-            for self_score, other_score in itertools.product(*all_scores):
-                new_score = openmc.CrossScore(self_score, other_score,
-                                              binary_op)
+            for self_score, other_score in product(*all_scores):
+                new_score = cross_score(self_score, other_score, binary_op)
                 new_tally.scores.append(new_score)
 
         # Update the new tally's filter strides
@@ -2142,7 +2153,7 @@ def _swap_filters(self, filter1, filter2):
         std_dev = {}
 
         # Store the data from the misaligned structure
-        for i, (bin1, bin2) in enumerate(itertools.product(filter1_bins, filter2_bins)):
+        for i, (bin1, bin2) in enumerate(product(filter1_bins, filter2_bins)):
             filter_bins = [(bin1,), (bin2,)]
 
             if self.mean is not None:
@@ -2163,7 +2174,7 @@ def _swap_filters(self, filter1, filter2):
         self._update_filter_strides()
 
         # Realign the data
-        for i, (bin1, bin2) in enumerate(itertools.product(filter1_bins, filter2_bins)):
+        for i, (bin1, bin2) in enumerate(product(filter1_bins, filter2_bins)):
             filter_bins = [(bin1,), (bin2,)]
             indices = self.get_filter_indices(filters, filter_bins)
 


Test patch:
diff --git a/tests/test_filter_energyfun/results_true.dat b/tests/test_filter_energyfun/results_true.dat
--- a/tests/test_filter_energyfun/results_true.dat
+++ b/tests/test_filter_energyfun/results_true.dat
@@ -1,2 +1,2 @@
-   energyfunction nuclide      score     mean  std. dev.
-0  02180f5f310ee4   Am241  (n,gamma) 1.00e-01   9.97e-03
+   energyfunction nuclide                    score     mean  std. dev.
+0  02180f5f310ee4   Am241  ((n,gamma) / (n,gamma)) 1.00e-01   9.97e-03