Tabulated1D.__call__ snaps any x within 1e-5 relative of the first or last abscissa to that endpoint's y, because np.isclose is called with atol=1e-14 but numpy's default rtol=1e-5 still applies. The scalar path does not do this, so the two disagree, and the array path is the wrong one.
https://github.com/shimwell/endf-python/blob/main/src/endf/function.py#L135-L136
# In some cases, x values might be outside the tabulated region due only
# to precision, so we check if they're close and set them equal if so.
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]
The comment says "due only to precision", and atol=1e-14 reads as that intent. But the effective tolerance is 1e-14 + 1e-5 * |x[0]|, which at 6 MeV is 67 eV and at a 5991 eV edge is 0.06 eV. Any genuine grid point inside that window is overwritten with the endpoint value, which at a threshold is zero.
Two confirmed cases
Ba L1 photoionization (photoat-056_Ba_000.endf, MF=23 MT=535), x[0] = 5991.0:
xs(5991.009) # 21012.35694510297 scalar, correct
xs(np.array([5991.009])) # 0.0 array, snapped
# gap 0.009 eV; window 1e-5 * 5991 = 0.0599 eV
Zero cross section 9 meV above an L1 edge whose value is 21,012 barns.
N15 (n,n3) on its ACE grid, x[0] = 6749151.0, next grid point 6749200.0:
# gap 49 eV; window 1e-5 * 6749151 = 67.5 eV -> snapped to y[0] = 0
That one point is then missing from the MT 4 inelastic sum, so MT 4 no longer equals the sum of MT 51..91, by 4.61e-6 barns.
Note the MF=3 tabulation for the same reaction is not affected (x[0] = 6747200, gap 2000 eV, window 67 eV), which is why this only appears after NJOY processing. The window scales with the energy, so it bites wherever a grid happens to be finer than 1e-5 relative, and nowhere else. That is why it is rare and easy to miss.
Where it comes from: openmc has it too
These two lines are in openmc as well, at openmc/data/function.py:207-208, identical. endf-python inherited them in 503e4cc, "Initial ENDF functionality ported over from OpenMC", and they are unchanged in paulromano/endf-python main (src/endf/function.py:122-123) and in every fork of it.
Verified on openmc 0.15.3 with the real Ba tabulation, whose first abscissa IS the edge:
import numpy as np, openmc.data
t = openmc.data.Tabulated1D(
np.array([5991.0, 5991.0, 6167.88862, 6350.0]),
np.array([0.0, 21012.409, 19989.3069, 19016.02]),
breakpoints=[4], interpolation=[2])
t(5991.009) # 21012.35694510297 scalar
t(np.array([5991.009]))[0] # 0.0 array
So this is not a regression against openmc; it is a long-standing behaviour that openmc, endf-python and every downstream consumer share. It matters more here than in openmc because a converter evaluates whole grids at once, which is the array path, whereas openmc's transport reads its own processed tables.
A note on testing it: the window is 1e-5 * |x[0]|, so a synthetic tabulation with a leading point far below the edge will not reproduce it. x[0] has to be at or near the value being probed. My first attempt at this comparison used a leading point at 5000 eV and wrongly concluded openmc was unaffected.
How it was found
Converting ENDF/B-VIII.1 to a different format and diffing against previously published output. Of 33 photoatomic elements compared, Ba was the only one that showed it: only a grid point that lands inside the window is affected, and most grids have none. On the neutron side it showed as N15's MT 4 disagreeing with its own components at one point in 979.
Suggested fix
Pass rtol=0 so the tolerance is the absolute one the comment intends:
y[np.isclose(x, self.x[0], rtol=0, atol=1e-14)] = self.y[0]
y[np.isclose(x, self.x[-1], rtol=0, atol=1e-14)] = self.y[-1]
A regression test worth having: evaluate the same x scalar and array and require agreement, using a tabulation whose second abscissa is within 1e-5 relative of the first.
Tabulated1D.__call__snaps any x within 1e-5 relative of the first or last abscissa to that endpoint's y, becausenp.iscloseis called withatol=1e-14but numpy's defaultrtol=1e-5still applies. The scalar path does not do this, so the two disagree, and the array path is the wrong one.https://github.com/shimwell/endf-python/blob/main/src/endf/function.py#L135-L136
The comment says "due only to precision", and
atol=1e-14reads as that intent. But the effective tolerance is1e-14 + 1e-5 * |x[0]|, which at 6 MeV is 67 eV and at a 5991 eV edge is 0.06 eV. Any genuine grid point inside that window is overwritten with the endpoint value, which at a threshold is zero.Two confirmed cases
Ba L1 photoionization (
photoat-056_Ba_000.endf, MF=23 MT=535),x[0] = 5991.0:Zero cross section 9 meV above an L1 edge whose value is 21,012 barns.
N15 (n,n3) on its ACE grid,
x[0] = 6749151.0, next grid point 6749200.0:# gap 49 eV; window 1e-5 * 6749151 = 67.5 eV -> snapped to y[0] = 0That one point is then missing from the MT 4 inelastic sum, so MT 4 no longer equals the sum of MT 51..91, by 4.61e-6 barns.
Note the MF=3 tabulation for the same reaction is not affected (
x[0] = 6747200, gap 2000 eV, window 67 eV), which is why this only appears after NJOY processing. The window scales with the energy, so it bites wherever a grid happens to be finer than 1e-5 relative, and nowhere else. That is why it is rare and easy to miss.Where it comes from: openmc has it too
These two lines are in openmc as well, at
openmc/data/function.py:207-208, identical.endf-pythoninherited them in 503e4cc, "Initial ENDF functionality ported over from OpenMC", and they are unchanged inpaulromano/endf-pythonmain (src/endf/function.py:122-123) and in every fork of it.Verified on openmc 0.15.3 with the real Ba tabulation, whose first abscissa IS the edge:
So this is not a regression against openmc; it is a long-standing behaviour that openmc, endf-python and every downstream consumer share. It matters more here than in openmc because a converter evaluates whole grids at once, which is the array path, whereas openmc's transport reads its own processed tables.
A note on testing it: the window is
1e-5 * |x[0]|, so a synthetic tabulation with a leading point far below the edge will not reproduce it.x[0]has to be at or near the value being probed. My first attempt at this comparison used a leading point at 5000 eV and wrongly concluded openmc was unaffected.How it was found
Converting ENDF/B-VIII.1 to a different format and diffing against previously published output. Of 33 photoatomic elements compared, Ba was the only one that showed it: only a grid point that lands inside the window is affected, and most grids have none. On the neutron side it showed as N15's MT 4 disagreeing with its own components at one point in 979.
Suggested fix
Pass
rtol=0so the tolerance is the absolute one the comment intends:A regression test worth having: evaluate the same x scalar and array and require agreement, using a tabulation whose second abscissa is within 1e-5 relative of the first.