Repository: openmc-dev/openmc

Issue:
Handling temperatures just outside the library
I've run into an issue with the `interpolation` temperature method near 293.6 K.  With the MCNP ENDF71 library, the coldest light water s(alpha, beta) table is at 293.606 K, above our default temperature of 293.600.  So trying to use the MCNP s(a, b) with interpolation will give

```
 ERROR: Nuclear data library does not contain cross sections for c_H_in_H2O at
        temperatures that bound 294 K.
```

The error is technically correct, but it would be nice if we built in some tolerance for these tiny out-of-bounds errors.  Maybe we also want some way to have the `interpolation` method revert back to `nearest` when the temperature of a cell is very close to a temperature in the data.



Discussion:
@smharper One thought on this issue -- is it not sufficient to just change the default temperature to 293.606 K in this case? i.e.
```Python
settings.temperature = {'default': 293.606}
```
Setting the default to 293.606 does not work, but 293.607 does.

@johnnyliu27, you said you wanted to try a small OpenMC PR, right?  This could be a good one to fix.  If you're interested, maybe we could talk about how to add this tolerance to the interpolation code.
Thanks! @smharper I'll try to fix this one.
@johnnyliu27, you and I discussed whether or not we should output a warning for when the code substitutes 293.606 for 293.600.  After thinking about it a little more, I don't think we should print out a warning.  If we print too many warnings, we risk having users ignore them.  If our tolerance for this substitution is something like 1 K, then I'm not worried about a "silent" adjustment because the uncertainty on the underlying data itself probably dwarfs the effects of a <1 K change.
@smharper Yes I agree, then the value for the tolerance might be the next thing to discuss, like 1.0 K or 0.5 K? As @paulromano mentioned in #838, the value for "default room temperature" could be either 293.15 K or 293.6 K, and a tolerance of 0.5 K will work for both values.
Let's use 1K.  That makes the most sense to me since we round to the nearest K anyway in our programming structures.
I met some challenges when giving a second try to fix this issue. I was trying to fix it by a more flexible fix inside "_sab_header.F90_" than just changing the default temperature in OpenMC. My plan was to adjust the desired temperature (like 293.6 K, the default) to the coldest temperature (~293.6061 K) in the sab library if it’s below the coldest sab temperature by less than 1 K. However, this will cause problems when computing cross sections, because then we will need to use two different temperature values for sab isotopes and other isotopes. (I assume we don’t want to adjust the temperature for non-sab isotopes.) If so, we also have to modify the `temperature` variable in a more complex way.

As I talked with @smharper about this issue, an `if` statement in "_sab_header.F90_" can make the code more forgiving if the desired temperature is just slightly below the coldest sab temperature, but we don’t want to put the same `if` statement into the module for computing sab cross sections. Then in this case, maybe changing the default temperature is a better idea for this specific issue.

Any thoughts on this? Thanks!

Resolving patch:
diff --git a/openmc/settings.py b/openmc/settings.py
--- a/openmc/settings.py
+++ b/openmc/settings.py
@@ -186,13 +186,15 @@ class Settings:
         'default', 'method', 'range', 'tolerance', and 'multipole'. The value
         for 'default' should be a float representing the default temperature in
         Kelvin. The value for 'method' should be 'nearest' or 'interpolation'.
-        If the method is 'nearest', 'tolerance' indicates a range of temperature
-        within which cross sections may be used. The value for 'range' should be
-        a pair of minimum and maximum temperatures which are used to indicate
-        that cross sections be loaded at all temperatures within the
-        range. 'multipole' is a boolean indicating whether or not the windowed
-        multipole method should be used to evaluate resolved resonance cross
-        sections.
+        If the method is 'nearest', 'tolerance' indicates a range of
+        temperature within which cross sections may be used. If the method is
+        'interpolation', 'tolerance' indicates the range of temperatures outside
+        of the available cross section temperatures where cross sections will
+        evaluate to the nearer bound. The value for 'range' should be a pair of
+        minimum and maximum temperatures which are used to indicate that cross
+        sections be loaded at all temperatures within the range. 'multipole' is
+        a boolean indicating whether or not the windowed multipole method should
+        be used to evaluate resolved resonance cross sections.
     trace : tuple or list
         Show detailed information about a single particle, indicated by three
         integers: the batch number, generation number, and particle number


Test patch:
diff --git a/tests/unit_tests/test_temp_interp.py b/tests/unit_tests/test_temp_interp.py
--- a/tests/unit_tests/test_temp_interp.py
+++ b/tests/unit_tests/test_temp_interp.py
@@ -10,7 +10,7 @@
 
 
 def make_fake_cross_section():
-    """Create fake U235 nuclide
+    """Create fake U235 nuclide with a fake thermal scattering library attached
 
     This nuclide is designed to have k_inf=1 at 300 K, k_inf=2 at 600 K, and
     k_inf=1 at 900 K. The absorption cross section is also constant with
@@ -81,8 +81,62 @@ def cross_section(value):
     # Export HDF5 file
     u235_fake.export_to_hdf5('U235_fake.h5', 'w')
 
+    # Create a fake thermal scattering library attached to the fake U235 data
+    c_U_fake = openmc.data.ThermalScattering("c_U_fake", 1.9968, 4.9, [0.0253])
+    c_U_fake.nuclides = ['U235']
+
+    # Create elastic reaction
+    bragg_edges = [0.00370672, 0.00494229]
+    factors = [0.00375735, 0.01386287]
+    coherent_xs = openmc.data.CoherentElastic(bragg_edges, factors)
+    incoherent_xs_294 = openmc.data.Tabulated1D([0.00370672, 0.00370672], [0.00370672, 0.00370672])
+    elastic_xs_base = openmc.data.Sum((coherent_xs, incoherent_xs_294))
+    elastic_xs = {'294K': elastic_xs_base, '600K': elastic_xs_base}
+    coherent_dist = openmc.data.CoherentElasticAE(coherent_xs)
+    incoherent_dist_294 = openmc.data.IncoherentElasticAEDiscrete([
+        [-0.6, -0.18, 0.18, 0.6], [-0.6, -0.18, 0.18, 0.6]
+    ])
+    incoherent_dist_600 = openmc.data.IncoherentElasticAEDiscrete([
+        [-0.1, -0.2, 0.2, 0.1], [-0.1, -0.2, 0.2, 0.1]
+    ])
+    elastic_dist = {
+        '294K': openmc.data.MixedElasticAE(coherent_dist, incoherent_dist_294),
+        '600K': openmc.data.MixedElasticAE(coherent_dist, incoherent_dist_600)
+        }
+    c_U_fake.elastic = openmc.data.ThermalScatteringReaction(elastic_xs, elastic_dist)
+
+    # Create inelastic reaction
+    inelastic_xs = {
+        '294K': openmc.data.Tabulated1D([1.0e-5, 4.9], [13.4, 3.35]),
+        '600K': openmc.data.Tabulated1D([1.0e-2, 10], [1.4, 5])
+        }
+    breakpoints = [3]
+    interpolation = [2]
+    energy = [1.0e-5, 4.3e-2, 4.9]
+    energy_out = [
+        openmc.data.Tabular([0.0002, 0.067, 0.146, 0.366], [0.25, 0.25, 0.25, 0.25]),
+        openmc.data.Tabular([0.0001, 0.009, 0.137, 0.277], [0.25, 0.25, 0.25, 0.25]),
+        openmc.data.Tabular([0.0579, 4.555, 4.803, 4.874], [0.25, 0.25, 0.25, 0.25]),
+    ]
+    for eout in energy_out:
+        eout.normalize()
+        eout.c = eout.cdf()
+    discrete = openmc.stats.Discrete([-0.9, -0.6, -0.3, -0.1, 0.1, 0.3, 0.6, 0.9], [1/8]*8)
+    discrete.c = discrete.cdf()[1:]
+    mu = [[discrete]*4]*3
+    dist = openmc.data.IncoherentInelasticAE(
+        breakpoints, interpolation, energy, energy_out, mu)
+    inelastic_dist = {'294K': dist, '600K': dist}
+    inelastic = openmc.data.ThermalScatteringReaction(inelastic_xs, inelastic_dist)
+    c_U_fake.inelastic = inelastic
+
+    # Export HDF5 file
+    c_U_fake.export_to_hdf5("c_U_fake.h5")
+
+    # Create a data library of the fake nuclide and its thermal scattering data
     lib = openmc.data.DataLibrary()
     lib.register_file('U235_fake.h5')
+    lib.register_file("c_U_fake.h5")
     lib.export_to_xml('cross_sections_fake.xml')
 
 
@@ -119,21 +173,23 @@ def model(tmp_path_factory):
 
 
 @pytest.mark.parametrize(
-    ["method", "temperature", "fission_expected"],
+    ["method", "temperature", "fission_expected", "tolerance"],
     [
-        ("nearest", 300.0, 0.5),
-        ("nearest", 600.0, 1.0),
-        ("nearest", 900.0, 0.5),
-        ("interpolation", 360.0, 0.6),
-        ("interpolation", 450.0, 0.75),
-        ("interpolation", 540.0, 0.9),
-        ("interpolation", 660.0, 0.9),
-        ("interpolation", 750.0, 0.75),
-        ("interpolation", 840.0, 0.6),
+        ("nearest", 300.0, 0.5, 10),
+        ("nearest", 600.0, 1.0, 10),
+        ("nearest", 900.0, 0.5, 10),
+        ("interpolation", 360.0, 0.6, 10),
+        ("interpolation", 450.0, 0.75, 10),
+        ("interpolation", 540.0, 0.9, 10),
+        ("interpolation", 660.0, 0.9, 10),
+        ("interpolation", 750.0, 0.75, 10),
+        ("interpolation", 840.0, 0.6, 10),
+        ("interpolation", 295.0, 0.5, 10),
+        ("interpolation", 990.0, 0.5, 100),
     ]
 )
-def test_interpolation(model, method, temperature, fission_expected):
-    model.settings.temperature = {'method': method, 'default': temperature}
+def test_interpolation(model, method, temperature, fission_expected, tolerance):
+    model.settings.temperature = {'method': method, 'default': temperature, "tolerance": tolerance}
     sp_filename = model.run()
     with openmc.StatePoint(sp_filename) as sp:
         t = sp.tallies[model.tallies[0].id]
@@ -152,3 +208,33 @@ def test_interpolation(model, method, temperature, fission_expected):
             assert k.n == pytest.approx(nu*fission_expected)
         else:
             assert abs(k.n - nu*fission_expected) <= 3*k.s
+
+
+def test_temperature_interpolation_tolerance(model):
+    """Test applying global and cell temperatures with thermal scattering libraries
+    """
+    model.materials[0].add_s_alpha_beta("c_U_fake")
+
+    # Default k-effective, using the thermal scattering data's minimum available temperature
+    model.settings.temperature = {'method': "nearest", 'default': 294, "tolerance": 50}
+    sp_filename = model.run()
+    with openmc.StatePoint(sp_filename) as sp:
+        default_k = sp.keff.n
+
+    # Get k-effective with temperature below the minimum but in interpolation mode
+    model.settings.temperature = {'method': "interpolation", 'default': 255, "tolerance": 50}
+    sp_filename = model.run()
+    with openmc.StatePoint(sp_filename) as sp:
+        interpolated_k = sp.keff.n
+
+    # Get the k-effective with the temperature applied to the cell, instead of globally
+    model.settings.temperature = {'method': "interpolation", 'default': 500, "tolerance": 50}
+    for cell in model.geometry.get_all_cells().values():
+        cell.temperature = 275
+    sp_filename = model.run()
+    with openmc.StatePoint(sp_filename) as sp:
+        cell_k = sp.keff.n
+
+    # All calculated k-effectives should be equal
+    assert default_k == pytest.approx(interpolated_k)
+    assert interpolated_k == pytest.approx(cell_k)