Powder diffraction data reduction#
This notebook will guide you through the data reduction for the powder diffraction 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 event
time-of-arrivalcoordinate to something more useful (\(\lambda\), \(d\), …)Improve the estimate of neutron
time-of-flightSave the reduced data to disk, in a state that is ready for analysis/fitting.
import numpy as np
import scipp as sc
import plopp as pp
import powder_utils as utils
Process the run with a reference sample: Si#
Load the NeXus file data#
By default, we will use the data we generated in yesterday’s simulation:
folder = "../3-mcstas/output_sample_Si"
We now load the data:
sample_si = utils.load_powder(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).
sample_si
- events: 350963
- position(events)vector3m[ 4.97355937 0.2625 160.42201312], [ 3.10883934e+00 -3.75000000e-02 1.57522981e+02], ..., [ -1.97529876 0.4125 160.05783889], [-1.32519983e+00 -3.75000000e-02 1.58526302e+02]
Values:
array([[ 4.97355937e+00, 2.62500000e-01, 1.60422013e+02], [ 3.10883934e+00, -3.75000000e-02, 1.57522981e+02], [ 3.13580735e+00, 1.12500000e-01, 1.57537320e+02], ..., [-1.32519983e+00, 3.37500000e-01, 1.58526302e+02], [-1.97529876e+00, 4.12500000e-01, 1.60057839e+02], [-1.32519983e+00, -3.75000000e-02, 1.58526302e+02]]) - sample_position()vector3m[ 1.47918852 0. 160.62043787]
Values:
array([ 1.47918852, 0. , 160.62043787]) - sim_source_time(events)float64s0.001, 0.003, ..., 0.001, 0.001
Values:
array([0.00135692, 0.00342941, 0.00342941, ..., 0.00051112, 0.00051112, 0.00051112]) - sim_speed(events)float64m/s1444.839, 2607.873, ..., 1348.320, 1348.320
Values:
array([1444.83867569, 2607.87309718, 2607.87309718, ..., 1348.3203984 , 1348.3203984 , 1348.3203984 ]) - sim_wavelength(events)float64Å2.738, 1.517, ..., 2.934, 2.934
Values:
array([2.73804549, 1.51695802, 1.51695802, ..., 2.93404596, 2.93404596, 2.93404596]) - source_position()vector3m[0. 0. 0.]
Values:
array([0., 0., 0.]) - time_origin()float64s0.0
Values:
array(0.) - toa(events)float64s0.115, 0.066, ..., 0.122, 0.122
Values:
array([0.11494571, 0.06636537, 0.06636959, ..., 0.12225075, 0.12225605, 0.1222465 ]) - x(events)float64m4.974, 3.109, ..., -1.975, -1.325
Values:
array([ 4.97355937, 3.10883934, 3.13580735, ..., -1.32519983, -1.97529876, -1.32519983]) - y(events)float64m0.262, -0.037, ..., 0.413, -0.037
Values:
array([ 0.2625, -0.0375, 0.1125, ..., 0.3375, 0.4125, -0.0375]) - z(events)float64m160.422, 157.523, ..., 160.058, 158.526
Values:
array([160.42201312, 157.52298114, 157.53732029, ..., 158.52630177, 160.05783889, 158.52630177])
- (events)float64counts148.534, 1.224, ..., 3.150e-15, 2.622e-15σ = 148.534, 1.224, ..., 3.150e-15, 2.622e-15
Values:
array([1.48534080e+02, 1.22398205e+00, 1.22398205e+00, ..., 2.62220306e-15, 3.14989726e-15, 2.62220306e-15])
Variances (σ²):
array([2.20623729e+04, 1.49813206e+00, 1.49813206e+00, ..., 6.87594887e-30, 9.92185272e-30, 6.87594887e-30])
Visualize the data#
Here is a 2D visualization of the neutron counts, histogrammed along the x and toadimensions:
sample_si.hist(x=200, toa=200).plot(norm="log", vmin=1.0e-2)
We can also visualize the events in 3D, which show the shape of the detector panels:
pp.scatter3d(sample_si[::20], pos="position", cbar=True, vmin=0.01, pixel_size=0.07)
Coordinate transformations#
The first step of this data reduction workflow is to convert the raw event coordinates (position, time-of-flight) to something physically meaningful such as wavelength (\(\lambda\)) or d-spacing (\(d\)).
Scipp has a dedicated method for this called transform_coords (see docs here).
We begin with a standard graph which describes how to compute e.g. the wavelength from the other coordinates in the raw data.
from scippneutron.conversion.graph.beamline import beamline
from scippneutron.conversion.graph.tof import kinematic
graph = {**beamline(scatter=True), **kinematic("tof")}
def compute_tof(toa, time_origin):
return toa - time_origin
graph["tof"] = compute_tof
sc.show_graph(graph, simplified=True)
To compute the wavelength of all the events, we simply call transform_coords on our loaded data,
requesting the name of the coordinate we want in the output ("wavelength"),
as well as providing it the graph to be used to compute it (i.e. the one we defined just above).
This yields
si_wav = sample_si.transform_coords(["wavelength", "two_theta"], graph=graph)
si_wav
- events: 350963
- L1()float64m160.62724881363712
Values:
array(160.62724881) - L2(events)float64m3.510, 3.500, ..., 3.524, 3.500
Values:
array([3.50982995, 3.50020089, 3.50180757, ..., 3.51623467, 3.52422421, 3.50020089]) - Ltotal(events)float64m164.137, 164.127, ..., 164.151, 164.127
Values:
array([164.13707876, 164.1274497 , 164.12905638, ..., 164.14348348, 164.15147302, 164.1274497 ]) - incident_beam()vector3m[ 1.47918852 0. 160.62043787]
Values:
array([ 1.47918852, 0. , 160.62043787]) - position(events)vector3m[ 4.97355937 0.2625 160.42201312], [ 3.10883934e+00 -3.75000000e-02 1.57522981e+02], ..., [ -1.97529876 0.4125 160.05783889], [-1.32519983e+00 -3.75000000e-02 1.58526302e+02]
Values:
array([[ 4.97355937e+00, 2.62500000e-01, 1.60422013e+02], [ 3.10883934e+00, -3.75000000e-02, 1.57522981e+02], [ 3.13580735e+00, 1.12500000e-01, 1.57537320e+02], ..., [-1.32519983e+00, 3.37500000e-01, 1.58526302e+02], [-1.97529876e+00, 4.12500000e-01, 1.60057839e+02], [-1.32519983e+00, -3.75000000e-02, 1.58526302e+02]]) - sample_position()vector3m[ 1.47918852 0. 160.62043787]
Values:
array([ 1.47918852, 0. , 160.62043787]) - scattered_beam(events)vector3m[ 3.49437085 0.2625 -0.19842476], [ 1.62965082 -0.0375 -3.09745673], ..., [-3.45448728 0.4125 -0.56259898], [-2.80438834 -0.0375 -2.0941361 ]
Values:
array([[ 3.49437085, 0.2625 , -0.19842476], [ 1.62965082, -0.0375 , -3.09745673], [ 1.65661884, 0.1125 , -3.08311758], ..., [-2.80438834, 0.3375 , -2.0941361 ], [-3.45448728, 0.4125 , -0.56259898], [-2.80438834, -0.0375 , -2.0941361 ]]) - sim_source_time(events)float64s0.001, 0.003, ..., 0.001, 0.001
Values:
array([0.00135692, 0.00342941, 0.00342941, ..., 0.00051112, 0.00051112, 0.00051112]) - sim_speed(events)float64m/s1444.839, 2607.873, ..., 1348.320, 1348.320
Values:
array([1444.83867569, 2607.87309718, 2607.87309718, ..., 1348.3203984 , 1348.3203984 , 1348.3203984 ]) - sim_wavelength(events)float64Å2.738, 1.517, ..., 2.934, 2.934
Values:
array([2.73804549, 1.51695802, 1.51695802, ..., 2.93404596, 2.93404596, 2.93404596]) - source_position()vector3m[0. 0. 0.]
Values:
array([0., 0., 0.]) - time_origin()float64s0.0
Values:
array(0.) - toa(events)float64s0.115, 0.066, ..., 0.122, 0.122
Values:
array([0.11494571, 0.06636537, 0.06636959, ..., 0.12225075, 0.12225605, 0.1222465 ]) - tof(events)float64s0.115, 0.066, ..., 0.122, 0.122
Values:
array([0.11494571, 0.06636537, 0.06636959, ..., 0.12225075, 0.12225605, 0.1222465 ]) - two_theta(events)float64rad1.618, 2.648, ..., 1.740, 2.221
Values:
array([1.6181774 , 2.64794815, 2.63838919, ..., 2.21790405, 1.74026384, 2.2213701 ]) - wavelength(events)float64Å2.770, 1.600, ..., 2.946, 2.947
Values:
array([2.7704229 , 1.59963287, 1.59971897, ..., 2.94637416, 2.94635841, 2.94655956]) - x(events)float64m4.974, 3.109, ..., -1.975, -1.325
Values:
array([ 4.97355937, 3.10883934, 3.13580735, ..., -1.32519983, -1.97529876, -1.32519983]) - y(events)float64m0.262, -0.037, ..., 0.413, -0.037
Values:
array([ 0.2625, -0.0375, 0.1125, ..., 0.3375, 0.4125, -0.0375]) - z(events)float64m160.422, 157.523, ..., 160.058, 158.526
Values:
array([160.42201312, 157.52298114, 157.53732029, ..., 158.52630177, 160.05783889, 158.52630177])
- (events)float64counts148.534, 1.224, ..., 3.150e-15, 2.622e-15σ = 148.534, 1.224, ..., 3.150e-15, 2.622e-15
Values:
array([1.48534080e+02, 1.22398205e+00, 1.22398205e+00, ..., 2.62220306e-15, 3.14989726e-15, 2.62220306e-15])
Variances (σ²):
array([2.20623729e+04, 1.49813206e+00, 1.49813206e+00, ..., 6.87594887e-30, 9.92185272e-30, 6.87594887e-30])
The result has a wavelength coordinate. We can also plot the result:
si_wav.hist(two_theta=128, wavelength=200).plot(norm="log", vmin=1.0e-3)
Exercise 1: convert raw data to d-spacing#
Instead of wavelength as in the example above, the task is now to convert the raw data to interplanar lattice spacing \(d\).
The transformation graph is missing the computation for \(d\) so you will have to add it in yourself. As a reminder, \(d\) is computed as follows
You have to:
create a function that computes \(d\)
add it to the graph with name “dspacing”
call
transform_coordsusing the new graph
Note that the graph already contains the necessary components to compute the scattering angle \(2 \theta\) (called two_theta in code).
Solution:
Histogram the data in d-spacing#
The final step in processing the sample run is to histogram the data into \(d\) bins.
dbins = sc.linspace("dspacing", 0.7, 2.5, 901, unit="angstrom")
ntheta = 128
si_dspacing_hist = si_dspacing.hist(two_theta=ntheta, dspacing=dbins)
si_dspacing_hist.plot(norm="log", vmin=1.0e-3)
si_dspacing_hist.sum("two_theta").plot()
Exercise 2: Correct the time-of-flight#
Time-of-flight at a long-pulsed source#
The time-of-flight of a neutron is typically not known from experimental measurements.
The only two parameters that we are able to record are usually the time at which the pulse was emitted (named time_origin in our dataset),
and the timestamp when a neutron was detected by a detector pixel (named toa, standing for time-of-arrival).
The difference between these two times is commonly used to compute an approximate time-of-flight. This approximation performs well at neutron sources with a short pulse, where it is satisfactory to assume that all neutrons left the source at the same time.
At a long-pulsed source (lasting a few milliseconds) such as ESS, with high-precision instruments, this approximation is no longer valid.
This is in fact the reason why in the \(2\theta\) vs d-spacing plot, the bright lines appear curved while they should in reality be vertical.
In the following exercise, we will add a correction to our time-of-flight computation to improve our results.
Finding a new time-distance origin#
One way to look at the problem is to visualize the paths of each neutron that made it to the detector, on a time-distance diagram.
The neutrons begin at the source (bottom left), travel along the guide, go through the chopper, and finally arrive at the detectors (top).
utils.time_distance_diagram(si_wav)
As the figure above illustrates,
the current time-of-flight computation (tof = toa - time_origin) assumes that all neutrons were born at \(t = 0\) (black dot).
This is clearly not the case from looking at the neutron tracks.
Exercise: find a new origin#
Inspect the time-distance diagram and try to find a point which would be a better representation of a common origin for all tracks.
Set the origin as coordinates on the original data (hint: you will need to update the
source_positionandtime_origincoordinates).Compute wavelength/d-spacing again.
Solution:
better_dspacing.hist(two_theta=ntheta, dspacing=dbins).plot(norm="log", vmin=1.0e-3)
pp.plot(
{
"original": si_dspacing_hist.sum("two_theta"),
"improved": better_dspacing.hist(two_theta=ntheta, dspacing=dbins).sum(
"two_theta"
),
}
)
Exercise 3: Process the Vanadium run#
We now need to repeat the process (load data, correct time-of-flight, convert to d-spacing) for the Vanadium run.
folder = "../3-mcstas/output_sample_vanadium"
sample_van = utils.load_powder(folder)
van_dspacing.hist(two_theta=ntheta, dspacing=dbins).plot(norm="log", vmin=1.0e-3)
Normalize by Vanadium#
The next step in our data reduction is to normalize our sample signal by the Vanadium counts.
Vanadium is an almost perfect incoherent scatterer (scatters equally in all directions) and the signal from that run can be used to correct for difference in detector exposure and sensitivity.
Performing accurate normalization is a complex subject, but in our simple idealised set-up, it basically boils down to a simple division operation.
We first histogram the sample and vanadium data using the same binning for both:
num = better_dspacing.hist(two_theta=ntheta, dspacing=dbins)
den = van_dspacing.hist(two_theta=num.coords["two_theta"], dspacing=dbins)
For best results, it is common practice to smooth the Vanadium signal before using it for normalization, to remove any noise that mostly comes from collection statistics rather than detector efficiency.
We thus smooth the Vandium histogram using a simple Gaussian filter.
We will normalize the signal as a 1D histogram, so the data is first summed over the two_theta dimension.
In Scipp, because of the physical units,
we can define a physical kernel width as opposed to having to determine how many neighbours are necessary for good smoothing.
# Smooth the Vanadium run
from scipp.scipy.ndimage import gaussian_filter
smooth_vana = gaussian_filter(
sc.values(den.sum("two_theta")), sigma=sc.scalar(0.03, unit="angstrom")
)
pp.plot(
{"orig": den.sum("two_theta"), "smooth": smooth_vana}, title="Vanadium - smoothed"
)
Exercise 4: perform the normalization#
Finally, we divide the sample histogram num (summed over two_theta) by the smoothed Vanadium.
normed.plot()
You can compare to the plot above (before normalization) and see that the relative intensities of the Si peaks have changed.
Save to disk#
We now need to save our reduced normalized data to a file, that will then be used by the analysis software in the next chapter.
For this, we use a small helper function:
utils.save_xye(
"reduced_Si.xye",
event_data=better_dspacing, # Needed for saving metadata required for analysis
normalized_histogram=normed,
)
Exercise 5: Process and normalize main sample: LBCO#
Repeat the process for the LBCO sample:
load data
correct time-of-flight
convert to d-spacing
normalize by Vanadium
save to disk
folder = "../3-mcstas/output_sample_LBCO"
Sciline workflow#
Pause here if you have not yet been introduced to Sciline!
Below we build a re-usable Sciline workflow for reducing the powder diffraction 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 powder_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 powder_utils import *
def load(folder: Foldername[RunType]) -> RawData[RunType]:
"""Load raw data from file"""
return RawData[RunType](utils.load_powder(folder))
def correct_beamline_params(
data: RawData[RunType], source_position: SourcePosition, time_origin: TimeOrigin
) -> CorrectedData[RunType]:
"""Correct the source position and time origin for a better time-of-flight estimate"""
corrected = data.copy(deep=False)
corrected.coords["source_position"] = source_position
corrected.coords["time_origin"] = time_origin
return CorrectedData[RunType](corrected)
def to_dspacing(
data: CorrectedData[RunType], graph: CoordTransformGraph
) -> DspacingData[RunType]:
"""Compute dspacing for events"""
return DspacingData[RunType](data.transform_coords("dspacing", graph=graph))
def to_histogram(
data: DspacingData[RunType],
two_theta_bins: TwoThetaBins,
dspacing_bins: DspacingBins,
) -> HistogramTwothetaDspacing[RunType]:
"""Histogram event data using two_theta and dspacing binning"""
return HistogramTwothetaDspacing[RunType](
data.hist(two_theta=two_theta_bins, dspacing=dspacing_bins)
)
def smooth_vanadium(
van: HistogramTwothetaDspacing[VanadiumRun], sigma: GaussianSmoothingSigma
) -> SmoothedVanadium:
"""Smooth the Vanadium data using a Gaussian kernel"""
return SmoothedVanadium(
gaussian_filter(sc.values(van.sum("two_theta")), sigma=sigma)
)
def sum_over_two_theta(
sample: HistogramTwothetaDspacing[SampleRun],
) -> SampleDspacing:
"""Sum the sample data over the two_theta dimension so we are left with dspacing"""
return SampleDspacing(sample.sum("two_theta"))
def normalize(sample: SampleDspacing, van: SmoothedVanadium) -> NormalizedDspacing:
"""Normalize sample by vanadium"""
return NormalizedDspacing(sample / van)
wf = sl.Pipeline(
(
load,
correct_beamline_params,
to_dspacing,
to_histogram,
smooth_vanadium,
sum_over_two_theta,
normalize,
),
constraints={RunType: [SampleRun, VanadiumRun]},
)
wf.visualize()
Exercise 6: 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(NormalizedDspacing) to compute the final normalized result for the Si and LBCO samples,
remembering to update the Foldername[SampleRun] for each case.
Solution:
Exercise 7 (optional): Modify the dspacing binning#
Change the number of bins used in the dspacing dimension on the workflow, and recompute the result.
Hint: use wf[DspacingBins] = ...
Solution:
Exercise 8: Debugging detector banks#
The detector team suspects that one of the two cylindrical banks is faulty (recording too many counts), and you have to help them find out which one (if any!).
Identify an intermediate result in the workflow graph that contains the necessary information.
Separate the events between left and right detector banks (hint: you can either use masks or smart indexing:
da[da.coords['x'] < x0])Histogram both banks using the dspacing bins
Plot both histograms on the same axes (hint: send a dict to the plotting function
pp.plot({"left": left_hist, "right": right_hist}))
Solution: