QENS data reduction#

This notebook will guide you through the data reduction for the QENS experiment that you simulated with McStas yesterday.

The following is a basic outline of what this notebook will cover:

  • Loading the NeXus files that contain the data

  • Inspect/visualize the data contents

  • How to convert the raw time-of-flight coordinate to something more useful (\(\Delta E\))

  • Write the results to file

import matplotlib.pyplot as plt
import numpy as np
import scipp as sc
import plopp as pp
import scippneutron as scn
import qens_utils as utils

Process the elastic sample data#

Load raw data#

Begin by loading and investigating the data obtained from the elastic sample.

folder = "../3-mcstas/QENS_elastic"
events = utils.load_qens(folder)

The first way to inspect the data is to view the HTML representation of the loaded object.

Try to explore what is inside the data, and familiarize yourself with the different sections (Dimensions, Coordinates, Data).

events
Show/Hide data repr Show/Hide attributes
scipp.DataArray (82.07 MB)
    • event: 827445
    • analyzer_angle
      (event)
      float64
      rad
      0.042, 0.042, ..., 0.042, 0.042
      Values:
      array([0.04157062, 0.04157062, 0.04157062, ..., 0.04157062, 0.04157062, 0.04157062])
    • analyzer_dspacing
      (event)
      float64
      Å
      3.135, 3.135, ..., 3.135, 3.135
      Values:
      array([3.135, 3.135, 3.135, ..., 3.135, 3.135, 3.135])
    • analyzer_position
      (event)
      vector3
      m
      [1.02606043 0. 2.81907786], [1.02606043 0. 2.81907786], ..., [ 1.92836283 0. -2.29813333], [ 1.92836283 0. -2.29813333]
      Values:
      array([[ 1.02606043, 0. , 2.81907786], [ 1.02606043, 0. , 2.81907786], [ 1.02606043, 0. , 2.81907786], ..., [ 1.92836283, 0. , -2.29813333], [ 1.92836283, 0. , -2.29813333], [ 1.92836283, 0. , -2.29813333]])
    • bank
      (event)
      int64
      𝟙
      0, 0, ..., 4, 4
      Values:
      array([0, 0, 0, ..., 4, 4, 4])
    • position
      (event)
      vector3
      m
      [0.07171201 0.26229029 0.19702712], [0.07171201 0.24138052 0.19702712], ..., [ 0.13477448 0.24615886 -0.16061798], [ 0.13477448 0.2002675 -0.16061798]
      Values:
      array([[ 0.07171201, 0.26229029, 0.19702712], [ 0.07171201, 0.24138052, 0.19702712], [ 0.07171201, 0.22557 , 0.19702712], ..., [ 0.13477448, 0.22093961, -0.16061798], [ 0.13477448, 0.24615886, -0.16061798], [ 0.13477448, 0.2002675 , -0.16061798]])
    • sample_position
      ()
      vector3
      m
      [0. 0. 0.]
      Values:
      array([0., 0., 0.])
    • source_position
      ()
      vector3
      m
      [ -0. -0. -150.]
      Values:
      array([ -0., -0., -150.])
    • tof
      (event)
      float64
      ms
      246.688, 246.588, ..., 246.688, 246.714
      Values:
      array([246.68843277, 246.58823271, 246.88496122, ..., 246.587281 , 246.68829246, 246.7140274 ])
    • y
      (event)
      float64
      m
      0.262, 0.241, ..., 0.246, 0.200
      Values:
      array([0.26229029, 0.24138052, 0.22557 , ..., 0.22093961, 0.24615886, 0.2002675 ])
    • (event)
      float64
      counts
      0.050, 2.213e-10, ..., 1.162e-06, 0.078
      σ = 0.050, 2.213e-10, ..., 1.162e-06, 0.078
      Values:
      array([5.00310376e-02, 2.21291145e-10, 1.38550316e-09, ..., 1.11857210e-01, 1.16187581e-06, 7.76805716e-02])

      Variances (σ²):
      array([2.50310472e-03, 4.89697707e-20, 1.91961900e-18, ..., 1.25120354e-02, 1.34995539e-12, 6.03427121e-03])

Visualize the raw data#

Here is a histogram of the raw data in tof:

events.hist(tof=100).plot()
../_images/4af4656a4385b125be61e23b8ba93a754d25ffb540578943f2b8078e918c3d7b.svg

It is more enlightening to look at a 2D histogram in tof and y as this shows a defect of the detector for a certain y-range:

events.hist(tof=100, y=100).plot()
../_images/e484fb135f58cbb406b0791c7f5e3ea7421acd3f3f3e359af5d5bd8b450aa3e6.svg

Exercise 1: mask bad region#

We need to handle the events that are affected by the detector defect. First, we should find exactly what part of the detector is affected.

Exercise 1.1: find bad region#

We can make a plot with a slider (see slicer) to explore the different banks. Move the slider below to find the faulty bank (number in the box next to the slider).

Also, zoom in on the bad region and read off the range in y that has a problem.

Note:

The plot below is not interactive in the book. Download and run the Jupyter notebook yourself to use the controls.

fig = pp.slicer(events.group("bank").hist(tof=100, y=100), mode="single")
fig

Exercise 1.2: create and apply mask#

Now, we create a mask for the bad region and apply it to the data. We do so by defining a condition on the event coordinates with respect to the ranges that we found in exercise 1a.

Steps:

  1. Define a mask by comparing the y coordinate to the range found above and comparing the bank coordinate to the faulty bank found above. Combine the individual conditions into a single mask.

  2. Apply the mask by making a copy of the events using masked_events = events.copy() and assigning the mask to masked_events.masks.

Hints:

  • Use events.coords["y"] >= mask_y_min to get a mask that masks all events with a y value greater than mask_y_min.

    • Similarly, you can compare with the maximum y value that you want to mask using the < operator.

  • Use events.coords['bank'] == masked_bank to get a mask for the bank that has a fault.

  • Combine masks using mask = mask_1 & mask_2 to get a union of mask_1 and mask_2.

Solution:

Hide code cell content

mask_y_min = sc.scalar(0.243, unit="m")
mask_y_max = sc.scalar(0.252, unit="m")
masked_bank = 1
mask = (
    (events.coords["y"] >= mask_y_min)
    & (events.coords["y"] < mask_y_max)
    & (events.coords["bank"] == masked_bank)
)

masked_events = events.copy()
masked_events.masks["bad_timing"] = mask
masked_events
Show/Hide data repr Show/Hide attributes
scipp.DataArray (82.86 MB)
    • event: 827445
    • analyzer_angle
      (event)
      float64
      rad
      0.042, 0.042, ..., 0.042, 0.042
      Values:
      array([0.04157062, 0.04157062, 0.04157062, ..., 0.04157062, 0.04157062, 0.04157062])
    • analyzer_dspacing
      (event)
      float64
      Å
      3.135, 3.135, ..., 3.135, 3.135
      Values:
      array([3.135, 3.135, 3.135, ..., 3.135, 3.135, 3.135])
    • analyzer_position
      (event)
      vector3
      m
      [1.02606043 0. 2.81907786], [1.02606043 0. 2.81907786], ..., [ 1.92836283 0. -2.29813333], [ 1.92836283 0. -2.29813333]
      Values:
      array([[ 1.02606043, 0. , 2.81907786], [ 1.02606043, 0. , 2.81907786], [ 1.02606043, 0. , 2.81907786], ..., [ 1.92836283, 0. , -2.29813333], [ 1.92836283, 0. , -2.29813333], [ 1.92836283, 0. , -2.29813333]])
    • bank
      (event)
      int64
      𝟙
      0, 0, ..., 4, 4
      Values:
      array([0, 0, 0, ..., 4, 4, 4])
    • position
      (event)
      vector3
      m
      [0.07171201 0.26229029 0.19702712], [0.07171201 0.24138052 0.19702712], ..., [ 0.13477448 0.24615886 -0.16061798], [ 0.13477448 0.2002675 -0.16061798]
      Values:
      array([[ 0.07171201, 0.26229029, 0.19702712], [ 0.07171201, 0.24138052, 0.19702712], [ 0.07171201, 0.22557 , 0.19702712], ..., [ 0.13477448, 0.22093961, -0.16061798], [ 0.13477448, 0.24615886, -0.16061798], [ 0.13477448, 0.2002675 , -0.16061798]])
    • sample_position
      ()
      vector3
      m
      [0. 0. 0.]
      Values:
      array([0., 0., 0.])
    • source_position
      ()
      vector3
      m
      [ -0. -0. -150.]
      Values:
      array([ -0., -0., -150.])
    • tof
      (event)
      float64
      ms
      246.688, 246.588, ..., 246.688, 246.714
      Values:
      array([246.68843277, 246.58823271, 246.88496122, ..., 246.587281 , 246.68829246, 246.7140274 ])
    • y
      (event)
      float64
      m
      0.262, 0.241, ..., 0.246, 0.200
      Values:
      array([0.26229029, 0.24138052, 0.22557 , ..., 0.22093961, 0.24615886, 0.2002675 ])
    • (event)
      float64
      counts
      0.050, 2.213e-10, ..., 1.162e-06, 0.078
      σ = 0.050, 2.213e-10, ..., 1.162e-06, 0.078
      Values:
      array([5.00310376e-02, 2.21291145e-10, 1.38550316e-09, ..., 1.11857210e-01, 1.16187581e-06, 7.76805716e-02])

      Variances (σ²):
      array([2.50310472e-03, 4.89697707e-20, 1.91961900e-18, ..., 1.25120354e-02, 1.34995539e-12, 6.03427121e-03])
    • bad_timing
      (event)
      bool
      False, False, ..., False, False
      Values:
      array([False, False, False, ..., False, False, False])

We can now make a tof histogram again to see how the masked events were dropped:

masked_events.hist(tof=100).plot()
../_images/f21428fbe862e181a2f66a972c7b64ba97ed80edfc27d1d27c00a183f2478c57.svg

We can also see the mask more directly by histogramming in the dimensions that we used for masking:

masked_events.group("bank").hist(y=100).plot()
../_images/1ee5bb45a676edec3d454932968f16d20f42093918549894b68ba0a3194bf61f.svg

Exercise 1.3: explore the masked data (optional)#

If you want to, make some more plots like we did above to see how the mask impacts the data. For example, try making a 2D plot in y and tof with the masked data.

Hint:

You can make slicer plot with multiple lines to compare masked and unmasked data using

pp.slicer({"unmasked": data_1, "masked": data_2}, mode="single")

And as the data, you can group the events by bank and histogram in tof. See the plots above for how that can be done.

Solution:

Hide code cell content

fig = pp.slicer(
    {
        "unmasked": events.group("bank").hist(tof=100),
        "masked": masked_events.group("bank").hist(tof=100),
    },
    mode="single",
)
fig

Transform to energy transfer and momentum transfer#

The next step is to compute the measured energy transfer and momentum transfer in the sample from time-of-flight and the other coordinates of our data. We use scipp.transform_coords for this purpose.

ScippNeutron provides some pre-defined functions for coordinate transformations, see the documentation for a list. In particular, it provides scippneutron.conversion.tof.energy_transfer_indirect_from_tof which computes the energy transfer for an indirect-geometry spectrometer:

\[\Delta E = \frac{m_n L_1^2}{2 {(t-t_0)}^2} - \mathsf{final\_energy}\,, \qquad t_0 = \sqrt{\frac{L_2^2 m_n}{2 \mathsf{final\_energy}}}\,,\]

where \(m_n\) is the neutron mass, \(L_1\) is the primary flight path length (from source to sample), and \(L_2\) is the secondary flight path length (from sample to detector). The intermediate variable \(t_0\) is the the time of flight for the secondary path such that \((t - t_0)\) is the arrival time at the sample.

While ScippNeutron provides most of what we need, we have to define some custom components for our specific instrument. We require functions to compute

  • L2, the path length of the scattered neutron. The default function in ScippNeutron assumes a straight path but here, we need to take the reflection from the analyzer crystal into account: \(L_2 = |\overline{\mathsf{sample},\mathsf{analyzer}}| + |\overline{\mathsf{analyser},\mathsf{detector}}|\)

  • final_wavelength, the neutron wavelength that the analyzer selects for. We compute it from the known \(d\)-spacing of the analyzer \(d_a\) and the scattering angle off the analyzer \(\theta_a\): \(\mathsf{final\_wavelength} = 2 d_a \sin{\theta_a}\)

  • Q, the momentum transfer: \(Q = |k_i - k_f|\), where \(k_i\) and \(k_f\) are the incident and final wavevectors, respectively. Here, the neutrons scatter almost elasitcally, such that we use \(|k_i| = |k_f| = mathsf{final\_wavelength}^{-1}\). We define a function to compute \(Q\) already now but will only use it later.

  • final_energy, the energy of the neutrons that hit the detector (more below): \(\mathsf{final\_energy} = \displaystyle\frac{m_n}{2} v_f^2\)

The first three are implemented below. They use the positions and analyzer parameters in the input data.

def backscattered_l2(position, sample_position, analyzer_position):
    """
    Compute the length of the secondary flight path for backscattering off an analyzer.
    """
    return sc.norm(position - analyzer_position) + sc.norm(
        analyzer_position - sample_position
    )


def wavelength_from_analyzer(analyzer_dspacing, analyzer_angle):
    """
    Compute the neutron wavelength after scattering from the analyzer's d-spacing.

    Assuming Bragg scattering in the analyzer, the wavelength is
        wavelength = 2 * d * sin(theta)

    Where
        d is the analyzer's d-spacing,
        theta is the scattering angle or equivalently, the tilt of the analyzer
              w.r.t. to the sample-analyzer axis.
    """
    # 2*theta is the angle between transmitted and scattered beam.
    return (
        2
        * analyzer_dspacing
        * sc.sin(sc.scalar(np.pi / 2, unit="rad") - analyzer_angle.to(unit="rad"))
    )


def momentum_transfer(
    incident_beam, sample_position, analyzer_position, final_wavelength
):
    """
    Compute the absolute value of the momentum transfer.
    """
    scattered_beam = analyzer_position - sample_position
    kf = scattered_beam / sc.norm(scattered_beam) / final_wavelength

    # The data are close to the elastic line
    # => use incident_wavelength = final_wavelength
    ki = incident_beam / sc.norm(incident_beam) / final_wavelength

    return sc.norm(ki - kf)

We can start assembling the graph to compute the energy transfer:

from scippneutron.conversion.graph.beamline import beamline
from scippneutron.conversion.tof import energy_transfer_indirect_from_tof

graph = {
    # Functions for the beamline geometry.
    **beamline(scatter=True),
    # The default energy transfer function.
    "energy_transfer": energy_transfer_indirect_from_tof,
    # Replace L2 and two_theta with our own implementations.
    "L2": backscattered_l2,
    # Insert new functions for the wavelength and Q.
    "final_wavelength": wavelength_from_analyzer,
    "Q": momentum_transfer,
}
# Optional: remove unused functions in order to clean up the image below.
del graph["scattered_beam"]
del graph["two_theta"]
del graph["Ltotal"]
sc.show_graph(graph, simplified=True)
../_images/615a1425095019b71cd164daed60a7a79ee3d7dd6226b101a6d1b668d327098c.svg

Exercise 2: Compute energy transfer#

We can see that final_energy is not yet linked to final_wavelength in the graph. Your task is to implement a function def final_energy(final_wavelength) to fill in the gap.

The energy is given by

\[\mathsf{final\_energy} = \frac{m_n}{2} v_f^2, \qquad v_f = \frac{2\pi\hbar}{m_n \mathsf{final\_wavelength}},\]

where \(v_f\) is the speed of the scattered neutron and \(m_n\) is the neutron mass.

Exercise 2.1: Finish the graph#

Steps:

  • Define a new function that computes the final energy given the equations above.

    • Tip: Ensure that the result has unit meV.

    • Tip: Use sc.constants to get values for \(\hbar\) (or \(h\)) and \(m_n\).

  • Insert the new function into the graph.

Solution:

Hide code cell content

def final_energy(final_wavelength):
    """
    Compute the neutron energy after scattering.

    Uses
        final_energy = mn / 2 * final_speed**2
        final_speed = 2 * pi * hbar / mn / final_wavelength

    Where
        mn is the neutron mass,
        final_wavelength is the wavelength after scattering,
        final_speed is the speed after scattering.
    """
    return sc.to_unit(
        sc.constants.h**2 / 2 / sc.constants.neutron_mass / (final_wavelength**2),
        "meV",
    )


graph["final_energy"] = final_energy
sc.show_graph(graph, simplified=True)
../_images/97099930e3d4a9e7c8dffc7abdc6d88cda9f3937a911864643d2613dabbcd997.svg

Exercise 2.2: Compute energy transfer with the masked data#

Use masked_events.transform_coords to compute "energy_transfer". Assign the result to in_energy_transfer.

Hide code cell content

in_energy_transfer = masked_events.transform_coords("energy_transfer", graph=graph)
in_energy_transfer
Show/Hide data repr Show/Hide attributes
scipp.DataArray (108.11 MB)
    • event: 827445
    • L1
      ()
      float64
      m
      150.0
      Values:
      array(150.)
    • L2
      (event)
      float64
      m
      5.803, 5.801, ..., 5.801, 5.798
      Values:
      array([5.80262863, 5.80074911, 5.79943081, ..., 5.79906151, 5.80116498, 5.79750569])
    • analyzer_angle
      (event)
      float64
      rad
      0.042, 0.042, ..., 0.042, 0.042
      Values:
      array([0.04157062, 0.04157062, 0.04157062, ..., 0.04157062, 0.04157062, 0.04157062])
    • analyzer_dspacing
      (event)
      float64
      Å
      3.135, 3.135, ..., 3.135, 3.135
      Values:
      array([3.135, 3.135, 3.135, ..., 3.135, 3.135, 3.135])
    • analyzer_position
      (event)
      vector3
      m
      [1.02606043 0. 2.81907786], [1.02606043 0. 2.81907786], ..., [ 1.92836283 0. -2.29813333], [ 1.92836283 0. -2.29813333]
      Values:
      array([[ 1.02606043, 0. , 2.81907786], [ 1.02606043, 0. , 2.81907786], [ 1.02606043, 0. , 2.81907786], ..., [ 1.92836283, 0. , -2.29813333], [ 1.92836283, 0. , -2.29813333], [ 1.92836283, 0. , -2.29813333]])
    • bank
      (event)
      int64
      𝟙
      0, 0, ..., 4, 4
      Values:
      array([0, 0, 0, ..., 4, 4, 4])
    • energy_transfer
      (event)
      float64
      meV
      0.001, 0.002, ..., 0.001, -1.178e-05
      Values:
      array([ 5.79927784e-04, 2.28804697e-03, -2.95516244e-03, ..., 2.25779810e-03, 5.41695995e-04, -1.17803997e-05])
    • final_energy
      (event)
      float64
      meV
      2.084, 2.084, ..., 2.084, 2.084
      Values:
      array([2.08444914, 2.08444914, 2.08444914, ..., 2.08444914, 2.08444914, 2.08444914])
    • final_wavelength
      (event)
      float64
      Å
      6.265, 6.265, ..., 6.265, 6.265
      Values:
      array([6.26458314, 6.26458314, 6.26458314, ..., 6.26458314, 6.26458314, 6.26458314])
    • incident_beam
      ()
      vector3
      m
      [ 0. 0. 150.]
      Values:
      array([ 0., 0., 150.])
    • position
      (event)
      vector3
      m
      [0.07171201 0.26229029 0.19702712], [0.07171201 0.24138052 0.19702712], ..., [ 0.13477448 0.24615886 -0.16061798], [ 0.13477448 0.2002675 -0.16061798]
      Values:
      array([[ 0.07171201, 0.26229029, 0.19702712], [ 0.07171201, 0.24138052, 0.19702712], [ 0.07171201, 0.22557 , 0.19702712], ..., [ 0.13477448, 0.22093961, -0.16061798], [ 0.13477448, 0.24615886, -0.16061798], [ 0.13477448, 0.2002675 , -0.16061798]])
    • sample_position
      ()
      vector3
      m
      [0. 0. 0.]
      Values:
      array([0., 0., 0.])
    • source_position
      ()
      vector3
      m
      [ -0. -0. -150.]
      Values:
      array([ -0., -0., -150.])
    • tof
      (event)
      float64
      ms
      246.688, 246.588, ..., 246.688, 246.714
      Values:
      array([246.68843277, 246.58823271, 246.88496122, ..., 246.587281 , 246.68829246, 246.7140274 ])
    • y
      (event)
      float64
      m
      0.262, 0.241, ..., 0.246, 0.200
      Values:
      array([0.26229029, 0.24138052, 0.22557 , ..., 0.22093961, 0.24615886, 0.2002675 ])
    • (event)
      float64
      counts
      0.050, 2.213e-10, ..., 1.162e-06, 0.078
      σ = 0.050, 2.213e-10, ..., 1.162e-06, 0.078
      Values:
      array([5.00310376e-02, 2.21291145e-10, 1.38550316e-09, ..., 1.11857210e-01, 1.16187581e-06, 7.76805716e-02])

      Variances (σ²):
      array([2.50310472e-03, 4.89697707e-20, 1.91961900e-18, ..., 1.25120354e-02, 1.34995539e-12, 6.03427121e-03])
    • bad_timing
      (event)
      bool
      False, False, ..., False, False
      Values:
      array([False, False, False, ..., False, False, False])

Exercise 2.3: Compute energy transfer for unmasked data#

For comparison, also compute energy transfer with the original data without a mask. Assign the result to in_energy_transfer_unmasked.

Solution:

Hide code cell content

in_energy_transfer_unmasked = events.transform_coords("energy_transfer", graph=graph)

Now we can plot the masked and unmasked data to compare them. As always, we first need to histogram them.

masked_hist = in_energy_transfer.hist(energy_transfer=100)
unmasked_hist = in_energy_transfer_unmasked.hist(energy_transfer=100)

And finally, we can plot the data:

pp.plot(
    {
        "unmasked": unmasked_hist,
        "masked": masked_hist,
    }
)
../_images/4d77607a04ec071d2bc72f7b39aa764369eae058c4c7ca062a6ce6ad9482ae40.svg

Exercise 2.4: Compute energy and momentum transfer#

So far, we combined events for all momentum transfers when making 1D plots. Now, we want to resolve the momentum transfer and get data in 2 dimensions: energy transfer and Q. The coordinate transformation graph already contains a function to compute Q so we just need to call transform_coords again.

Compute both energy_transfer and Q from masked_events and assign the result to in_Q_E.

Hint

You can pass multiple output coordinate names to transform_coords using

data.transform_coords(["coord_1", "coord_2"], graph=graph)

Solution:

Hide code cell content

in_Q_E = masked_events.transform_coords(["energy_transfer", "Q"], graph=graph)
in_Q_E
Show/Hide data repr Show/Hide attributes
scipp.DataArray (114.43 MB)
    • event: 827445
    • L1
      ()
      float64
      m
      150.0
      Values:
      array(150.)
    • L2
      (event)
      float64
      m
      5.803, 5.801, ..., 5.801, 5.798
      Values:
      array([5.80262863, 5.80074911, 5.79943081, ..., 5.79906151, 5.80116498, 5.79750569])
    • Q
      (event)
      float64
      1/Å
      0.055, 0.055, ..., 0.300, 0.300
      Values:
      array([0.05543806, 0.05543806, 0.05543806, ..., 0.30000164, 0.30000164, 0.30000164])
    • analyzer_angle
      (event)
      float64
      rad
      0.042, 0.042, ..., 0.042, 0.042
      Values:
      array([0.04157062, 0.04157062, 0.04157062, ..., 0.04157062, 0.04157062, 0.04157062])
    • analyzer_dspacing
      (event)
      float64
      Å
      3.135, 3.135, ..., 3.135, 3.135
      Values:
      array([3.135, 3.135, 3.135, ..., 3.135, 3.135, 3.135])
    • analyzer_position
      (event)
      vector3
      m
      [1.02606043 0. 2.81907786], [1.02606043 0. 2.81907786], ..., [ 1.92836283 0. -2.29813333], [ 1.92836283 0. -2.29813333]
      Values:
      array([[ 1.02606043, 0. , 2.81907786], [ 1.02606043, 0. , 2.81907786], [ 1.02606043, 0. , 2.81907786], ..., [ 1.92836283, 0. , -2.29813333], [ 1.92836283, 0. , -2.29813333], [ 1.92836283, 0. , -2.29813333]])
    • bank
      (event)
      int64
      𝟙
      0, 0, ..., 4, 4
      Values:
      array([0, 0, 0, ..., 4, 4, 4])
    • energy_transfer
      (event)
      float64
      meV
      0.001, 0.002, ..., 0.001, -1.178e-05
      Values:
      array([ 5.79927784e-04, 2.28804697e-03, -2.95516244e-03, ..., 2.25779810e-03, 5.41695995e-04, -1.17803997e-05])
    • final_energy
      (event)
      float64
      meV
      2.084, 2.084, ..., 2.084, 2.084
      Values:
      array([2.08444914, 2.08444914, 2.08444914, ..., 2.08444914, 2.08444914, 2.08444914])
    • final_wavelength
      (event)
      float64
      Å
      6.265, 6.265, ..., 6.265, 6.265
      Values:
      array([6.26458314, 6.26458314, 6.26458314, ..., 6.26458314, 6.26458314, 6.26458314])
    • incident_beam
      ()
      vector3
      m
      [ 0. 0. 150.]
      Values:
      array([ 0., 0., 150.])
    • position
      (event)
      vector3
      m
      [0.07171201 0.26229029 0.19702712], [0.07171201 0.24138052 0.19702712], ..., [ 0.13477448 0.24615886 -0.16061798], [ 0.13477448 0.2002675 -0.16061798]
      Values:
      array([[ 0.07171201, 0.26229029, 0.19702712], [ 0.07171201, 0.24138052, 0.19702712], [ 0.07171201, 0.22557 , 0.19702712], ..., [ 0.13477448, 0.22093961, -0.16061798], [ 0.13477448, 0.24615886, -0.16061798], [ 0.13477448, 0.2002675 , -0.16061798]])
    • sample_position
      ()
      vector3
      m
      [0. 0. 0.]
      Values:
      array([0., 0., 0.])
    • source_position
      ()
      vector3
      m
      [ -0. -0. -150.]
      Values:
      array([ -0., -0., -150.])
    • tof
      (event)
      float64
      ms
      246.688, 246.588, ..., 246.688, 246.714
      Values:
      array([246.68843277, 246.58823271, 246.88496122, ..., 246.587281 , 246.68829246, 246.7140274 ])
    • y
      (event)
      float64
      m
      0.262, 0.241, ..., 0.246, 0.200
      Values:
      array([0.26229029, 0.24138052, 0.22557 , ..., 0.22093961, 0.24615886, 0.2002675 ])
    • (event)
      float64
      counts
      0.050, 2.213e-10, ..., 1.162e-06, 0.078
      σ = 0.050, 2.213e-10, ..., 1.162e-06, 0.078
      Values:
      array([5.00310376e-02, 2.21291145e-10, 1.38550316e-09, ..., 1.11857210e-01, 1.16187581e-06, 7.76805716e-02])

      Variances (σ²):
      array([2.50310472e-03, 4.89697707e-20, 1.91961900e-18, ..., 1.25120354e-02, 1.34995539e-12, 6.03427121e-03])
    • bad_timing
      (event)
      bool
      False, False, ..., False, False
      Values:
      array([False, False, False, ..., False, False, False])

We can see that there are only 5 different Q values:

np.unique(in_Q_E.coords["Q"].values)
array([0.05543806, 0.13492303, 0.20521321, 0.26151845, 0.30000164])

The reason is that we use |k_i| = |k_f| to compute Q and each analyzer and thus detector bank only contributes a single Q value. We have 5 analyzers, so we also have 5 Q’s.

We want to group by those Q values and histogram in energy transfer as usual. For this, we use

  • .group("Q") to make a bin for each unique Q value

  • .hist(energy_transfer=100) to make a histogram with 100 bins in energy transfer.

Q_E_hist = in_Q_E.group("Q").hist(energy_transfer=100)
Q_E_hist.plot()
../_images/267c4379bce0175e84dbd7baddbb03bef2237f4c34fa241ad67a7a42aad8da28.svg

Exercise 2.5: Plot different Q’s separately (optional)#

If you want to, make a 1D plot (counts vs energy transfer) for each Q value.

Hints:

  • You can loop over the Q values using something like

for i in range(5):
    single_Q = Q_E_hist["Q", i]
  • You can plot multiple lines using a dictionary: sc.plot({"a": data_1, "b": data_2}).

Solution:

Hide code cell content

# Use a dict-comprehension to split the Q values:
individual_q = {
    f"Q = {Q_E_hist.coords['Q'][i]:c}": Q_E_hist["Q", i]
    for i in range(Q_E_hist.sizes["Q"])
}
sc.plot(individual_q)
../_images/8c317779021e00d2bff7b5d18f653976df2390fadd58108851dad9c84cc03499.svg

Exercise 3: Compute energy and momentum transfer for all samples#

We have only looked at one of our three samples so far. Now, we repeat the same procedure for the remaining two.

Your task is to load the other samples, mask the broken detector region, and compute energy_transfer and Q. (You don’t need to repeat the calculation for the unmasked data.)

Hints:

  • Write a function that encapsulates the whole procedure.

  • Store the results for all samples in a dict. This way, you can easily plot it using sc.plot.

  • Use a scalar variable with a unit, e.g. sc.scalar(0.01, unit="meV"), for the histogramming to get a constant bin width.

folders = (
    "../3-mcstas/QENS_elastic",
    "../3-mcstas/QENS_known_quasi_elastic",
    "../3-mcstas/QENS_unknown_quasi_elastic",
)

Solution:

Hide code cell content

def process_sample(raw_events, bin_width):
    mask_y_min = sc.scalar(0.243, unit="m")
    mask_y_max = sc.scalar(0.252, unit="m")
    masked_bank = 1
    mask = (
        (raw_events.coords["y"] >= mask_y_min)
        & (raw_events.coords["y"] < mask_y_max)
        & (raw_events.coords["bank"] == masked_bank)
    )
    masked_events = raw_events.copy(deep=False)
    masked_events.masks["bad_timing"] = mask

    converted = masked_events.transform_coords(["energy_transfer", "Q"], graph=graph)

    hist = converted.group("Q").hist(energy_transfer=bin_width)

    return hist


def process_all():
    bin_width = sc.scalar(0.001, unit="meV")
    results = {}
    for folder in folders:
        events = utils.load_qens(folder)
        # Remove the file path prefix from the folder name
        data_name = folder.split("/")[-1]
        results[data_name] = process_sample(events, bin_width)
    return results


results = process_all()

figs = []
for name, result in results.items():
    figs.append(result.plot(title=name))

figs[0] + figs[1] + figs[2]
../_images/47a26c39cd965eafb78ab513a7b23e44a7b5ac5f965e15013935f6bc23fac501.svg

Save result to disk#

Finally, we need to save our results to disk so that the reduced data can be forwarded to the next step in the pipeline (data analysis). We will use a Scipp HDF5 file for this.

This code assumes that you stored your histograms in a dict called results. If this is not the case, you need to modify the code below.

from scippneutron.io import save_xye

for name, result in results.items():
    # Rename 'energy_transfer' to 'E' because the
    # analysis software expects this name.
    data = result.rename(energy_transfer="E")
    data.save_hdf5(f"energy_transfer_{name}.h5")

Sciline workflow#

Pause here if you have not yet been introduced to Sciline!

Below we build a re-usable Sciline workflow for reducing the QENS data. We wrap the operations that were carried out throughout this notebook into function that have the correct type-annotations, building a pipeline of connected steps.

The custom types have been created for you in the qens_utils module, and are imported from there.

Take some time to read through the code and understand the different parts. Ask questions if anything is unclear.

import sciline as sl
from qens_utils import *


def load(folder: Foldername) -> RawData:
    """Load raw data from file"""
    return RawData(utils.load_qens(folder))


def mask_bad_region(
    data: RawData, masked_range: MaskedRange, masked_bank: MaskedBank
) -> MaskedData:
    y = data.coords["y"]
    mask = (
        (y >= masked_range[0])
        & (y < masked_range[1])
        & (data.coords["bank"] == masked_bank)
    )
    return MaskedData(data.assign_masks(bad_timing=mask))


def to_Q_and_E(
    masked: MaskedData, graph: CoordTransformGraph
) -> QEData:
    return QEData(
        masked.transform_coords(["energy_transfer", "Q"], graph=graph)
    )


def to_histogram(
    data: QEData, bin_width: BinWidth
) -> QEHistogram:
    return QEHistogram(data.group("Q").hist(energy_transfer=bin_width))


wf = sl.Pipeline((load, mask_bad_region, to_Q_and_E, to_histogram))

wf.visualize()
../_images/dbe9b0e7a76d3189e4976485863fde916933caff9e24f8bf80d09d90071e9f6e.svg

Exercise 4: Set the workflow parameters#

In the visualization above, the red boxes indicate missing values; parameters required by the computation that have yet to be set on the workflow.

Using the wf[TYPE] = VALUE syntax, set the missing parameters on the workflow.

Once you have done so, use wf.compute(QEHistogram) multiple times to compute the final result for each sample, making sure to change the folder to load the data from each time.

Make one figure per sample.

Solution:

Hide code cell content

# Params common to all runs
wf[CoordTransformGraph] = graph
wf[MaskedRange] = sc.scalar(0.243, unit="m"), sc.scalar(0.252, unit="m")
wf[MaskedBank] = 1
wf[BinWidth] = sc.scalar(0.001, unit="meV")

figs = []
for f in folders:
    wf[Foldername] = f
    figs.append(wf.compute(QEHistogram).plot(title=f.split("/")[-1]))

figs[0] + figs[1] + figs[2]
../_images/401f02784d04a7f0e8ad317787d606a21f81863274ea5828fce8c7f74f16521b.svg

Exercise 5: Make a \((y, \Delta E)\) map#

We wish to make a 2d plot of counts as a function of \(y\) and energy transfer for one of runs (e.g. elastic). You have to:

  • identify one of the intermediate results that would contain the necessary information

  • compute that intermediate result

  • histogram the result (200 bins in y and 200 bins in energy_transfer will do nicely)

  • plot it on a figure

Hint: The solution is very simple, and should only be 2-3 lines of python at most!

Solution:

Hide code cell content

wf[Foldername] = folders[0]
da = wf.compute(QEData)

da.hist(y=200, energy_transfer=200).plot()
../_images/c7f5efc4767ce3159e0b34aa36e00f30d655cbaaacba5f413b6aaea2b5bba3ad.svg