WFM#

Wavelength-frame-multiplication (WFM) is a technique commonly used at long-pulse facilities to improve the resolution of the results measured at the neutron detectors. See for example the article by Schmakat et al. (2020) for a description of how WFM works.

In this notebook, we show how tof can be used to convert a neutron time of arrival at the detector to a wavelength.

[1]:
import scipp as sc
import plopp as pp
import tof

Hz = sc.Unit("Hz")
deg = sc.Unit("deg")
meter = sc.Unit("m")

Create a source pulse#

We first create a source with one pulse containing 500,000 neutrons whose distribution follows the ESS time and wavelength profiles (both thermal and cold neutrons are included).

[2]:
source = tof.Source(facility="ess", neutrons=500_000)
source.plot()
[2]:
_images/wfm_3_0.svg
[3]:
source.data
[3]:
Show/Hide data repr Show/Hide attributes
scipp.DataArray (19.08 MB)
    • pulse: 1
    • event: 500000
    • birth_time
      (pulse, event)
      float64
      µs
      507.466, 2587.413, ..., 2319.159, 1173.183
      Values:
      array([[ 507.46572781, 2587.41253849, 3144.14216337, ..., 1100.69985221, 2319.15931466, 1173.1833816 ]], shape=(1, 500000))
    • id
      (pulse, event)
      int64
      0, 1, ..., 499998, 499999
      Values:
      array([[ 0, 1, 2, ..., 499997, 499998, 499999]], shape=(1, 500000))
    • speed
      (pulse, event)
      float64
      m/s
      1628.593, 2085.990, ..., 4608.413, 1494.948
      Values:
      array([[1628.59253404, 2085.99033339, 1700.66579217, ..., 1194.19444468, 4608.41298644, 1494.94835265]], shape=(1, 500000))
    • wavelength
      (pulse, event)
      float64
      Å
      2.429, 1.896, ..., 0.858, 2.646
      Values:
      array([[2.42911221, 1.89647763, 2.3261678 , ..., 3.31272183, 0.85843739, 2.64626801]], shape=(1, 500000))
    • (pulse, event)
      float64
      counts
      1.0, 1.0, ..., 1.0, 1.0
      Values:
      array([[1., 1., 1., ..., 1., 1., 1.]], shape=(1, 500000))

Component set-up#

Choppers#

We create a list of choppers that will be included in our beamline. In our case, we make two WFM choppers, and two frame-overlap choppers. All choppers have 6 openings.

Finally, we also add a pulse-overlap chopper with a single opening. These choppers are copied after the V20 ESS beamline at HZB.

[4]:
choppers = [
    tof.Chopper(
        frequency=70.0 * Hz,
        open=sc.array(
            dims=["cutout"],
            values=[98.71, 155.49, 208.26, 257.32, 302.91, 345.3],
            unit="deg",
        ),
        close=sc.array(
            dims=["cutout"],
            values=[109.7, 170.79, 227.56, 280.33, 329.37, 375.0],
            unit="deg",
        ),
        phase=47.10 * deg,
        distance=6.6 * meter,
        name="WFM1",
    ),
    tof.Chopper(
        frequency=70 * Hz,
        open=sc.array(
            dims=["cutout"],
            values=[80.04, 141.1, 197.88, 250.67, 299.73, 345.0],
            unit="deg",
        ),
        close=sc.array(
            dims=["cutout"],
            values=[91.03, 156.4, 217.18, 269.97, 322.74, 375.0],
            unit="deg",
        ),
        phase=76.76 * deg,
        distance=7.1 * meter,
        name="WFM2",
    ),
    tof.Chopper(
        frequency=56 * Hz,
        open=sc.array(
            dims=["cutout"],
            values=[74.6, 139.6, 194.3, 245.3, 294.8, 347.2],
            unit="deg",
        ),
        close=sc.array(
            dims=["cutout"],
            values=[95.2, 162.8, 216.1, 263.1, 310.5, 371.6],
            unit="deg",
        ),
        phase=62.40 * deg,
        distance=8.8 * meter,
        name="Frame-overlap 1",
    ),
    tof.Chopper(
        frequency=28 * Hz,
        open=sc.array(
            dims=["cutout"],
            values=[98.0, 154.0, 206.8, 254.0, 299.0, 344.65],
            unit="deg",
        ),
        close=sc.array(
            dims=["cutout"],
            values=[134.6, 190.06, 237.01, 280.88, 323.56, 373.76],
            unit="deg",
        ),
        phase=12.27 * deg,
        distance=15.9 * meter,
        name="Frame-overlap 2",
    ),
    tof.Chopper(
        frequency=7 * Hz,
        open=sc.array(
            dims=["cutout"],
            values=[30.0],
            unit="deg",
        ),
        close=sc.array(
            dims=["cutout"],
            values=[140.0],
            unit="deg",
        ),
        phase=0 * deg,
        distance=22 * meter,
        name="Pulse-overlap",
    ),
]

Detectors#

We add a single detector 32 meters from the source.

[5]:
detectors = [
    tof.Detector(distance=32.0 * meter, name="detector"),
]

Run the simulation#

We propagate our pulse of neutrons through the chopper cascade and inspect the results.

[6]:
model = tof.Model(source=source, choppers=choppers, detectors=detectors)
results = model.run()
results.plot(blocked_rays=5000)
[6]:
Plot(ax=<Axes: xlabel='Time [μs]', ylabel='Distance [m]'>, fig=<Figure size 640x480 with 2 Axes>)
_images/wfm_10_1.png

Wavelength as a function of time-of-arrival#

Plotting wavelength vs time-of-arrival#

Since we know the true wavelength of our neutrons, as well as the time at which the neutrons arrive at the detector (coordinate named toa in the detector reading), we can plot an image of the wavelengths as a function of time-of-arrival:

[7]:
# Squeeze the pulse dimension since we only have one pulse
events = results['detector'].data.squeeze()
# Remove the events that don't make it to the detector
events = events[~events.masks['blocked_by_others']]
# Histogram and plot
events.hist(wavelength=500, toa=500).plot(norm='log', grid=True)
[7]:
_images/wfm_12_0.svg

Defining a conversion from toa to wavelength#

The image above shows that there is a pretty tight correlation between time-of-arrival and wavelength.

We compute the mean wavelength inside a given toa bin to define a relation between toa and wavelength.

[8]:
binned = events.bin(toa=500)

# Weighted mean of wavelength inside each bin
mu = (
    binned.bins.data * binned.bins.coords['wavelength']
).bins.sum() / binned.bins.sum()

# Variance of wavelengths inside each bin
var = (
    binned.bins.data * (binned.bins.coords['wavelength'] - mu) ** 2
) / binned.bins.sum()

We can now overlay our mean wavelength function on the image above:

[9]:
import matplotlib.pyplot as plt

fig, ax = plt.subplots(2, 1)

f = events.hist(wavelength=500, tof=500).plot(norm='log', cbar=False, ax=ax[0])
mu.name = 'Wavelength'
mu.plot(ax=ax[0], color='C1', grid=True)
stddev = sc.sqrt(var.hist())
stddev.name = 'Standard deviation'
stddev.plot(ax=ax[1], grid=True)
fig.set_size_inches(6, 8)
fig.tight_layout()
_images/wfm_16_0.png

Computing wavelengths#

We set up an interpolator that will compute wavelengths given an array of toas.

[10]:
from scipp.scipy.interpolate import interp1d

# Set up interpolator
y = mu.copy()
y.coords['toa'] = sc.midpoints(y.coords['toa'])
f = interp1d(y, 'toa', bounds_error=False)

# Compute wavelengths
wavs = f(events.coords['toa'].rename_dims(event='toa'))
wavelengths = sc.DataArray(
    data=sc.ones(sizes=wavs.sizes, unit='counts'), coords={'wavelength': wavs.data}
).rename_dims(toa='event')
wavelengths
[10]:
Show/Hide data repr Show/Hide attributes
scipp.DataArray (570.08 KB)
    • event: 36431
    • wavelength
      (event)
      float64
      Å
      3.120, 2.577, ..., 5.685, 4.631
      Values:
      array([3.12009905, 2.57703063, 2.97127603, ..., 5.10171491, 5.68494471, 4.63079753], shape=(36431,))
    • (event)
      float64
      counts
      1.0, 1.0, ..., 1.0, 1.0
      Values:
      array([1., 1., 1., ..., 1., 1., 1.], shape=(36431,))

We can now compare our computed wavelengths to the true wavelengths of the neutrons. We also include a naive computation of the neutron wavelengths using the toa coordinate directly with the detector distance.

[11]:
naive = events.copy()
speed = detectors[0].distance / naive.coords['toa']
naive.coords['wavelength'] = sc.reciprocal(
    speed * sc.constants.m_n / sc.constants.h
).to(unit='angstrom')
[12]:
pp.plot(
    {
        'naive': naive.hist(wavelength=300),
        'wfm': wavelengths.hist(wavelength=300),
        'original': events.hist(wavelength=300),
    }
)
[12]:
_images/wfm_21_0.svg

We can see that the WFM estimate clearly outperforms the naive computation.

Multiple detectors#

Detectors in real life are usually composed of hundreds of thousands of pixels, and each pixel can have a different distance from the source. For example, the edges of a flat detector panel will be slightly further away from the source than the pixels in the center of the panel.

This does not mean we need to compute an interpolator for every detector pixel. We can instead find the range of pixel distances, and compute a 2d interpolator with a reasonable amount of bins as a function of distance.

Using a range of detectors#

Here, we assume that the minimum and maximum distances of our pixel range between 30 and 35 meters (in practise, the range would typically be much narrower).

[13]:
# Use 50 distances between 30m and 35m
distances = sc.linspace('distance', 30, 35, 50, unit='m')

detectors = [
    tof.Detector(distance=d, name=f"detector-{i}") for i, d in enumerate(distances)
]

# Re-run the simulation
model = tof.Model(source=source, choppers=choppers, detectors=detectors)
results = model.run()

We can now concatenate all the readings along the distance dimension into a single data array:

[14]:
events = [res.data.squeeze() for res in results.detectors.values()]
events = sc.concat(
    [ev[~ev.masks['blocked_by_others']] for ev in events], dim='distance'
)
events.coords['distance'] = distances
events
[14]:
Show/Hide data repr Show/Hide attributes
scipp.DataArray (42.84 MB)
    • distance: 50
    • event: 36431
    • birth_time
      (event)
      float64
      µs
      716.665, 1864.402, ..., 702.394, 700.675
      Values:
      array([ 716.66453486, 1864.4019367 , 907.10264746, ..., 1733.43697665, 702.39442866, 700.6747232 ], shape=(36431,))
    • distance
      (distance)
      float64
      m
      30.0, 30.102, ..., 34.898, 35.0
      Values:
      array([30. , 30.10204082, 30.20408163, 30.30612245, 30.40816327, 30.51020408, 30.6122449 , 30.71428571, 30.81632653, 30.91836735, 31.02040816, 31.12244898, 31.2244898 , 31.32653061, 31.42857143, 31.53061224, 31.63265306, 31.73469388, 31.83673469, 31.93877551, 32.04081633, 32.14285714, 32.24489796, 32.34693878, 32.44897959, 32.55102041, 32.65306122, 32.75510204, 32.85714286, 32.95918367, 33.06122449, 33.16326531, 33.26530612, 33.36734694, 33.46938776, 33.57142857, 33.67346939, 33.7755102 , 33.87755102, 33.97959184, 34.08163265, 34.18367347, 34.28571429, 34.3877551 , 34.48979592, 34.59183673, 34.69387755, 34.79591837, 34.89795918, 35. ])
    • id
      (event)
      int64
      37, 45, ..., 499976, 499983
      Values:
      array([ 37, 45, 67, ..., 499966, 499976, 499983], shape=(36431,))
    • speed
      (event)
      float64
      m/s
      1265.890, 1542.973, ..., 692.337, 855.694
      Values:
      array([1265.88990994, 1542.97296313, 1323.35896527, ..., 768.81238381, 692.33675671, 855.69400122], shape=(36431,))
    • toa
      (distance, event)
      float64
      µs
      2.442e+04, 2.131e+04, ..., 5.126e+04, 4.160e+04
      Values:
      array([[24415.40781767, 21307.38682163, 23576.68874407, ..., 40754.66066121, 44033.90862177, 35759.93651233], [24496.01578802, 21373.51942328, 23653.79617977, ..., 40887.38591183, 44181.29472446, 35879.18570209], [24576.62375837, 21439.65202493, 23730.90361547, ..., 41020.11116246, 44328.68082716, 35998.43489185], ..., [28203.9824241 , 24415.61909915, 27200.73822209, ..., 46992.74744071, 50961.05544855, 41364.648431 ], [28284.59039445, 24481.7517008 , 27277.8456578 , ..., 47125.47269134, 51108.44155125, 41483.89762076], [28365.1983648 , 24547.88430245, 27354.9530935 , ..., 47258.19794196, 51255.82765395, 41603.14681052]], shape=(50, 36431))
    • tof
      (distance, event)
      float64
      µs
      2.442e+04, 2.131e+04, ..., 5.126e+04, 4.160e+04
      Values:
      array([[24415.40781767, 21307.38682163, 23576.68874407, ..., 40754.66066121, 44033.90862177, 35759.93651233], [24496.01578802, 21373.51942328, 23653.79617977, ..., 40887.38591183, 44181.29472446, 35879.18570209], [24576.62375837, 21439.65202493, 23730.90361547, ..., 41020.11116246, 44328.68082716, 35998.43489185], ..., [28203.9824241 , 24415.61909915, 27200.73822209, ..., 46992.74744071, 50961.05544855, 41364.648431 ], [28284.59039445, 24481.7517008 , 27277.8456578 , ..., 47125.47269134, 51108.44155125, 41483.89762076], [28365.1983648 , 24547.88430245, 27354.9530935 , ..., 47258.19794196, 51255.82765395, 41603.14681052]], shape=(50, 36431))
    • wavelength
      (event)
      float64
      Å
      3.125, 2.564, ..., 5.714, 4.623
      Values:
      array([3.12510114, 2.56390365, 2.98938845, ..., 5.14564293, 5.71403146, 4.62318773], shape=(36431,))
    • (distance, event)
      float64
      counts
      1.0, 1.0, ..., 1.0, 1.0
      Values:
      array([[1., 1., 1., ..., 1., 1., 1.], [1., 1., 1., ..., 1., 1., 1.], [1., 1., 1., ..., 1., 1., 1.], ..., [1., 1., 1., ..., 1., 1., 1.], [1., 1., 1., ..., 1., 1., 1.], [1., 1., 1., ..., 1., 1., 1.]], shape=(50, 36431))
    • blocked_by_others
      (event)
      bool
      False, False, ..., False, False
      Values:
      array([False, False, False, ..., False, False, False], shape=(36431,))

Relation between toa and wavelength in 2D#

As in the previous section, we compute the weighted mean of the wavelengths inside each toa bin.

This results in a 2D function of wavelength as a function of toa and distance.

[15]:
binned = events.bin(toa=500, dim='event')

# Weighted mean of wavelength inside each bin
mu2d = (
    binned.bins.data * binned.bins.coords['wavelength']
).bins.sum() / binned.bins.sum()

mu2d.plot()
[15]:
_images/wfm_27_0.svg

Computing wavelengths with a 2D interpolator#

We now set up a 2D grid interpolator to compute wavelengths for our neutrons.

[16]:
from scipy.interpolate import RegularGridInterpolator

f = RegularGridInterpolator(
    (sc.midpoints(mu2d.coords['toa']).values, mu2d.coords['distance'].values),
    mu2d.values.T,
    method='linear',
    bounds_error=False,
)


# Flatten the event list
flat = events.flatten(to='event')

# Compute wavelengths
wavs = f((flat.coords['toa'].values, flat.coords['distance'].values))
flat.coords['wavelength'] = sc.array(dims=['event'], values=wavs, unit='angstrom')

We can now compare the results to the original wavelengths.

Once again, we also include the naive computation for reference.

[17]:
# Naive wavelength computation
naive = events.flatten(to='event')
speed = naive.coords['distance'] / naive.coords['toa']
naive.coords['wavelength'] = sc.reciprocal(
    speed * sc.constants.m_n / sc.constants.h
).to(unit='angstrom')

# True wavelengths
orig = events.hist(distance=40, wavelength=300)

# Plot
style = {'cmap': 'RdBu_r', 'vmin': -5, 'vmax': 5}
fig1 = ((flat.hist(**orig.coords) - orig) / orig).plot(title='WFM', **style)
fig2 = ((naive.hist(**orig.coords) - orig) / orig).plot(title='Naive', **style)
fig1 + fig2
[17]:
_images/wfm_31_0.svg

This once again illustrates the superiority of the WFM estimate.

An alternative way of comparing the accuracy of the methods is to look at the probability that a computed wavelength has relative error above \(x\), as a function of \(x\):

[18]:
true_wavs = events.flatten(to='event').coords['wavelength']

err_wfm = sc.abs(true_wavs - flat.coords['wavelength']) / true_wavs
err_naive = sc.abs(true_wavs - naive.coords['wavelength']) / true_wavs

bins = sc.geomspace('relative_error', 1e-3, 0.2, 101)
err_wfm = sc.cumsum(err_wfm.hist(relative_error=bins))
err_naive = sc.cumsum(err_naive.hist(relative_error=bins))

p = pp.plot(
    {'naive': 1 - err_naive / sc.max(err_naive), 'wfm': 1 - err_wfm / sc.max(err_wfm)},
    scale={'relative_error': 'log'},
)
p.canvas.ylabel = 'Probability of $rel. err. > x$'
p
[18]:
_images/wfm_33_0.svg