Repository: openmc-dev/openmc

Issue:
Photon heating
We are reading [number 5 element of JXS when generating incident photon data from ACE for MT 515](https://github.com/openmc-dev/openmc/blob/develop/openmc/data/photon.py#L882-L884),
``` python
class PhotonReaction(EqualityMixin):

    @classmethod
    def from_ace(cls, ace, mt):

        n = ace.nxs[3]
        elif mt == 515:
            # Pair production
            idx = ace.jxs[1] + 4*n

        # Store cross section
        xs = ace.xss[idx : idx+n].copy()
```
However, the ACE format says the [number 5 element of JXS  is the heating number](https://github.com/NuclearData/ACEFormat/blob/master/src/ContinuousEnergyPhoton.tex#L16-L27).
``` latex
    1        & ESZG   & Energy table \\
    2        & JINC   & Incoherent form factors \\
    3        & JCOH   & Coherent form factors \\
    4        & JFLO   & Fluorescence data \\
    5        & LHNM   & Heating numbers \\
    6        & LNEPS  & Number of electrons per shell \\
    7        & LBEPS  & Binding energy per shell \\
    8        & LPIPS  & Probability of interaction per shell \\
```
But I didn't see where the `pair production` should be from this table. 
@amandalund @paulromano Did I miss something here? BTW I'm trying to add the heating number in the photon library so we can do neutron-photon coupled energy deposition tallies.


Discussion:
It looks like the .tex documentation there for photoatomic data is incomplete. Pair production appears in the ESZG block starting at index `JXS(1) + 4*NXS(3)`. If you have a copy of MCNP5 user's manual, vol III, you'll see it there. Unfortunately that volume is under export control and can't be shared. You can find heating data in the ACE file starting at XSS(JXS(5)) and it is tabulated on the same energy grid as other cross sections. It's in units of MeV / collision, just like the neutron data.

Resolving patch:
diff --git a/openmc/capi/tally.py b/openmc/capi/tally.py
--- a/openmc/capi/tally.py
+++ b/openmc/capi/tally.py
@@ -89,7 +89,7 @@
     -5: 'absorption', -6: 'fission', -7: 'nu-fission', -8: 'kappa-fission',
     -9: 'current', -10: 'events', -11: 'delayed-nu-fission',
     -12: 'prompt-nu-fission', -13: 'inverse-velocity', -14: 'fission-q-prompt',
-    -15: 'fission-q-recoverable', -16: 'decay-rate'
+    -15: 'fission-q-recoverable', -16: 'decay-rate', -17: 'heating'
 }
 _ESTIMATORS = {
     1: 'analog', 2: 'tracklength', 3: 'collision'
diff --git a/openmc/data/function.py b/openmc/data/function.py
--- a/openmc/data/function.py
+++ b/openmc/data/function.py
@@ -3,6 +3,7 @@
 from functools import reduce
 from itertools import zip_longest
 from numbers import Real, Integral
+from math import exp, log
 
 import numpy as np
 
@@ -153,13 +154,11 @@ def __init__(self, x, y, breakpoints=None, interpolation=None):
         self.y = np.asarray(y)
 
     def __call__(self, x):
-        # Check if input is array or scalar
-        if isinstance(x, Iterable):
-            iterable = True
-            x = np.array(x)
-        else:
-            iterable = False
-            x = np.array([x], dtype=float)
+        # Check if input is scalar
+        if not isinstance(x, Iterable):
+            return self._interpolate_scalar(x)
+
+        x = np.array(x)
 
         # Create output array
         y = np.zeros_like(x)
@@ -208,7 +207,46 @@ def __call__(self, x):
         y[np.isclose(x, self.x[0], atol=1e-14)] = self.y[0]
         y[np.isclose(x, self.x[-1], atol=1e-14)] = self.y[-1]
 
-        return y if iterable else y[0]
+        return y
+
+    def _interpolate_scalar(self, x):
+        if x <= self._x[0]:
+            return self._y[0]
+        elif x >= self._x[-1]:
+            return self._y[-1]
+
+        # Get the index for interpolation
+        idx = np.searchsorted(self._x, x, side='right') - 1
+
+        # Loop over interpolation regions
+        for b, p in zip(self.breakpoints, self.interpolation):
+            if idx < b - 1:
+                break
+
+        xi = self._x[idx]       # low edge of the corresponding bin
+        xi1 = self._x[idx + 1]  # high edge of the corresponding bin
+        yi = self._y[idx]
+        yi1 = self._y[idx + 1]
+
+        if p == 1:
+            # Histogram
+            return yi
+
+        elif p == 2:
+            # Linear-linear
+            return yi + (x - xi)/(xi1 - xi)*(yi1 - yi)
+
+        elif p == 3:
+            # Linear-log
+            return yi + log(x/xi)/log(xi1/xi)*(yi1 - yi)
+
+        elif p == 4:
+            # Log-linear
+            return yi*exp((x - xi)/(xi1 - xi)*log(yi1/yi))
+
+        elif p == 5:
+            # Log-log
+            return yi*exp(log(x/xi)/log(xi1/xi)*log(yi1/yi))
 
     def __len__(self):
         return len(self.x)
diff --git a/openmc/data/photon.py b/openmc/data/photon.py
--- a/openmc/data/photon.py
+++ b/openmc/data/photon.py
@@ -3,12 +3,14 @@
 from copy import deepcopy
 from io import StringIO
 from numbers import Integral, Real
+from math import pi, sqrt
 import os
 
 import h5py
 import numpy as np
 import pandas as pd
 from scipy.interpolate import CubicSpline
+from scipy.integrate import quad
 
 from openmc.mixin import EqualityMixin
 import openmc.checkvalue as cv
@@ -19,6 +21,14 @@
 from .function import Tabulated1D
 
 
+# Constants
+MASS_ELECTRON_EV = 0.5109989461e6 # Electron mass energy
+PLANCK_C = 1.2398419739062977e4 # Planck's constant times c in eV-Angstroms
+FINE_STRUCTURE = 137.035999139 # Inverse fine structure constant
+CM_PER_ANGSTROM = 1.0e-8
+# classical electron radius in cm
+R0 = CM_PER_ANGSTROM * PLANCK_C / (2.0 * pi * FINE_STRUCTURE * MASS_ELECTRON_EV)
+
 # Electron subshell labels
 _SUBSHELLS = [None, 'K', 'L1', 'L2', 'L3', 'M1', 'M2', 'M3', 'M4', 'M5',
               'N1', 'N2', 'N3', 'N4', 'N5', 'N6', 'N7', 'O1', 'O2', 'O3',
@@ -33,6 +43,7 @@
     516: ('Total pair production', 'pair_production_total'),
     517: ('Pair production, nuclear field', 'pair_production_nuclear'),
     522: ('Photoelectric absorption', 'photoelectric'),
+    525: ('Heating', 'heating'),
     526: ('Electro-atomic scattering', 'electro_atomic_scat'),
     527: ('Electro-atomic bremsstrahlung', 'electro_atomic_brem'),
     528: ('Electro-atomic excitation', 'electro_atomic_excit'),
@@ -150,6 +161,7 @@ def __init__(self, binding_energy, num_electrons, transitions):
         self.binding_energy = binding_energy
         self.num_electrons = num_electrons
         self.transitions = transitions
+        self._e_fluorescence = {}
 
     @property
     def binding_energy(self):
@@ -379,6 +391,49 @@ def to_hdf5(self, group, shell):
                  _SUBSHELLS, range(len(_SUBSHELLS)))
             group.create_dataset('transitions', data=df.values.astype(float))
 
+    def energy_fluorescence(self, shell):
+        """Compute expected energy of fluorescent photons for the shell
+
+        Parameters
+        ----------
+        shell : str
+            The subshell to compute
+
+        Returns
+        -------
+        float
+            Energy of fluorescent photons
+
+        """
+
+        if shell not in self.binding_energy:
+            raise KeyError('Invalid shell {}.'.format(shell))
+
+        if shell in self._e_fluorescence:
+            # Already computed
+            return self._e_fluorescence[shell]
+        e = 0.0
+        if shell not in self.transitions or self.transitions[shell].empty:
+            e = self.binding_energy[shell]
+        else:
+            df = self.transitions[shell]
+            for primary, secondary, energy, prob in df.itertuples(index=False):
+                e_row = 0.0
+                if secondary is None:
+                    # Fluorescent photon release in radiative transition
+                    e_row += energy
+                else:
+                    # Fill the hole left by auger electron
+                    e_row += self.energy_fluorescence(secondary)
+
+                # Fill the photoelectron hole
+                e_row += self.energy_fluorescence(primary)
+
+                # Expected fluorescent photon energy
+                e += e_row * prob
+
+        self._e_fluorescence[shell] = e
+        return e
 
 class IncidentPhoton(EqualityMixin):
     r"""Photon interaction data.
@@ -499,9 +554,14 @@ def from_ace(cls, ace_or_filename):
 
         # Read each reaction
         data = cls(Z)
-        for mt in (502, 504, 515, 522):
+        for mt in (502, 504, 515, 522, 525):
             data.reactions[mt] = PhotonReaction.from_ace(ace, mt)
 
+        # Get heating cross sections [eV-barn] from factors [eV per collision]
+        # by multiplying with total xs
+        data.reactions[525].xs.y *= sum([data.reactions[mt].xs.y for mt in
+                                         (502, 504, 515, 522)])
+
         # Compton profiles
         n_shell = ace.nxs[5]
         if n_shell != 0:
@@ -631,6 +691,9 @@ def from_endf(cls, photoatomic, relaxation=None):
         # Add bremsstrahlung DCS data
         data._add_bremsstrahlung()
 
+        # Add heating cross sections
+        data._compute_heating()
+
         return data
 
     @classmethod
@@ -751,7 +814,7 @@ def export_to_hdf5(self, path, mode='a', libver='earliest'):
         designators = []
         for mt, rx in self.reactions.items():
             name, key = _REACTION_NAME[mt]
-            if mt in [502, 504, 515, 517, 522]:
+            if mt in (502, 504, 515, 517, 522, 525):
                 sub_group = group.create_group(key)
             elif mt >= 534 and mt <= 572:
                 # Subshell
@@ -858,6 +921,80 @@ def _add_bremsstrahlung(self):
         self.bremsstrahlung['photon_energy'] = _BREMSSTRAHLUNG['photon_energy']
         self.bremsstrahlung.update(_BREMSSTRAHLUNG[self.atomic_number])
 
+    def _compute_heating(self):
+        r"""Compute heating cross sections (KERMA)
+
+        Photon energy is deposited as energy loss in three reactions:
+        incoherent scattering, pair production and photoelectric effect.
+        The point-wise heating cross section is calculated as:
+
+        .. math::
+            \sigma_{Hx}(E) &= (E - \overline{E}_x(E)) \cdot \sigma_x(E), x \in \left\{I, PP, PE \right\}
+
+            \overline{E}_I(E) &= \frac {\int E' \sigma_I (E,E',\mu) d\mu} {\int \sigma_I (E,E',\mu) d\mu}
+
+            \overline{E}_{PP} &= 2 m_e c^2 = 1.022 \times 10^6 eV
+
+            \overline{E}_{PE} &= E(\text{fluorescent photons})
+
+        The differential cross section representation for incoherent
+        scattering can be found in the theory manual.
+
+        """
+
+        # Determine a union energy grid
+        energy = np.array([])
+        for mt in (504, 515, 517, 522):
+            if mt in self:
+                energy = np.union1d(energy, self[mt].xs.x)
+
+        heating_xs = np.zeros_like(energy)
+
+        # Incoherent scattering
+        if 504 in self:
+            rx = self[504]
+
+            def dsigma_dmu(mu, E):
+                k = E / MASS_ELECTRON_EV
+                krat = 1.0 / (1.0 + k * (1.0 - mu))
+                x = E * sqrt(0.5 * (1.0 - mu)) / PLANCK_C
+                return pi * R0*R0 * krat*krat * (krat + 1/krat +
+                       mu*mu - 1.0) * rx.scattering_factor(x)
+
+            def eout_dsigma_dmu(mu, E):
+                Eout = E / (1.0 + E / MASS_ELECTRON_EV * (1.0 - mu))
+                return Eout * dsigma_dmu(mu, E)
+
+            def eout_average(E):
+                integral_sigma = quad(dsigma_dmu, -1.0, 1.0,
+                                      args=(E,), epsabs=0.0, epsrel=1e-3)[0]
+                integral_sigma_e = quad(eout_dsigma_dmu, -1.0, 1.0,
+                                        args=(E,), epsabs=0.0, epsrel=1e-3)[0]
+                return integral_sigma_e / integral_sigma
+
+            e_out = np.vectorize(eout_average)(energy)
+            heating_xs += (energy - e_out) * rx.xs(energy)
+
+        # Pair production, electron field
+        if 515 in self:
+            heating_xs += (energy - 2*MASS_ELECTRON_EV)*self[515].xs(energy)
+
+        # Pair production, nuclear field
+        if 517 in self:
+            heating_xs += (energy - 2*MASS_ELECTRON_EV)*self[517].xs(energy)
+
+        # Photoelectric effect
+        if 522 in self:
+            # Account for fluorescent photons
+            for mt, rx in self.reactions.items():
+                if mt >= 534 and mt <= 572:
+                    shell = _REACTION_NAME[mt][1]
+                    e_f = self.atomic_relaxation.energy_fluorescence(shell)
+                    heating_xs += (energy - e_f) * rx.xs(energy)
+
+        heat_rx = PhotonReaction(525)
+        heat_rx.xs = Tabulated1D(energy, heating_xs, [energy.size], [5])
+        self.reactions[525] = heat_rx
 
 class PhotonReaction(EqualityMixin):
     """Photon-induced reaction
@@ -972,14 +1109,21 @@ def from_ace(cls, ace, mt):
         elif mt == 522:
             # Photoelectric
             idx = ace.jxs[1] + 3*n
+        elif mt == 525:
+            # Heating
+            idx = ace.jxs[5]
         else:
             raise ValueError('ACE photoatomic cross sections do not have '
                              'data for MT={}.'.format(mt))
 
         # Store cross section
         xs = ace.xss[idx : idx+n].copy()
-        nonzero = (xs != 0.0)
-        xs[nonzero] = np.exp(xs[nonzero])
+        if mt == 525:
+            # Get heating factors in [eV per collision]
+            xs *= EV_PER_MEV
+        else:
+            nonzero = (xs != 0.0)
+            xs[nonzero] = np.exp(xs[nonzero])
         rx.xs = Tabulated1D(energy, xs, [n], [5])
 
         # Get form factors for incoherent/coherent scattering


Test patch:
diff --git a/tests/regression_tests/photon_production/inputs_true.dat b/tests/regression_tests/photon_production/inputs_true.dat
--- a/tests/regression_tests/photon_production/inputs_true.dat
+++ b/tests/regression_tests/photon_production/inputs_true.dat
@@ -49,17 +49,20 @@
   </tally>
   <tally id="2">
     <filters>2</filters>
-    <scores>total</scores>
+    <nuclides>Al27 total</nuclides>
+    <scores>total heating</scores>
     <estimator>tracklength</estimator>
   </tally>
   <tally id="3">
     <filters>2</filters>
-    <scores>total</scores>
+    <nuclides>Al27 total</nuclides>
+    <scores>total heating</scores>
     <estimator>collision</estimator>
   </tally>
   <tally id="4">
     <filters>2</filters>
-    <scores>total</scores>
+    <nuclides>Al27 total</nuclides>
+    <scores>total heating</scores>
     <estimator>analog</estimator>
   </tally>
 </tallies>
diff --git a/tests/regression_tests/photon_production/results_true.dat b/tests/regression_tests/photon_production/results_true.dat
--- a/tests/regression_tests/photon_production/results_true.dat
+++ b/tests/regression_tests/photon_production/results_true.dat
@@ -2,11 +2,29 @@ tally 1:
 9.403000E-01
 8.841641E-01
 tally 2:
+1.819886E-01
+3.311985E-02
+1.960159E+05
+3.842224E+10
 8.281718E-01
 6.858685E-01
+1.960159E+05
+3.842224E+10
 tally 3:
+1.799069E-01
+3.236650E-02
+1.945225E+05
+3.783901E+10
 8.242000E-01
 6.793056E-01
+1.945225E+05
+3.783901E+10
 tally 4:
+2.308000E-01
+5.326864E-02
+1.988563E+05
+3.954384E+10
 8.242000E-01
 6.793056E-01
+1.988612E+05
+3.954578E+10
diff --git a/tests/regression_tests/photon_production/test.py b/tests/regression_tests/photon_production/test.py
--- a/tests/regression_tests/photon_production/test.py
+++ b/tests/regression_tests/photon_production/test.py
@@ -51,20 +51,23 @@ def _build_inputs(self):
         current_tally = openmc.Tally()
         current_tally.filters = [surface_filter, particle_filter]
         current_tally.scores = ['current']
-        total_tally_tracklength = openmc.Tally()
-        total_tally_tracklength.filters = [particle_filter]
-        total_tally_tracklength.scores = ['total']
-        total_tally_tracklength.estimator = 'tracklength'
-        total_tally_collision = openmc.Tally()
-        total_tally_collision.filters = [particle_filter]
-        total_tally_collision.scores = ['total']
-        total_tally_collision.estimator = 'collision'
-        total_tally_analog = openmc.Tally()
-        total_tally_analog.filters = [particle_filter]
-        total_tally_analog.scores = ['total']
-        total_tally_analog.estimator = 'analog'
-        tallies = openmc.Tallies([current_tally, total_tally_tracklength,
-                                  total_tally_collision, total_tally_analog])
+        tally_tracklength = openmc.Tally()
+        tally_tracklength.filters = [particle_filter]
+        tally_tracklength.scores = ['total', 'heating']
+        tally_tracklength.nuclides = ['Al27', 'total']
+        tally_tracklength.estimator = 'tracklength'
+        tally_collision = openmc.Tally()
+        tally_collision.filters = [particle_filter]
+        tally_collision.scores = ['total', 'heating']
+        tally_collision.nuclides = ['Al27', 'total']
+        tally_collision.estimator = 'collision'
+        tally_analog = openmc.Tally()
+        tally_analog.filters = [particle_filter]
+        tally_analog.scores = ['total', 'heating']
+        tally_analog.nuclides = ['Al27', 'total']
+        tally_analog.estimator = 'analog'
+        tallies = openmc.Tallies([current_tally, tally_tracklength,
+                                  tally_collision, tally_analog])
         tallies.export_to_xml()
 
     def _get_results(self):