essreduce 25.5.3__py3-none-any.whl → 25.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ess/reduce/time_of_flight/__init__.py +23 -15
- ess/reduce/time_of_flight/eto_to_tof.py +7 -284
- ess/reduce/time_of_flight/lut.py +478 -0
- ess/reduce/time_of_flight/types.py +0 -91
- ess/reduce/time_of_flight/workflow.py +9 -27
- {essreduce-25.5.3.dist-info → essreduce-25.7.0.dist-info}/METADATA +1 -1
- {essreduce-25.5.3.dist-info → essreduce-25.7.0.dist-info}/RECORD +11 -11
- {essreduce-25.5.3.dist-info → essreduce-25.7.0.dist-info}/WHEEL +1 -1
- ess/reduce/time_of_flight/simulation.py +0 -108
- {essreduce-25.5.3.dist-info → essreduce-25.7.0.dist-info}/entry_points.txt +0 -0
- {essreduce-25.5.3.dist-info → essreduce-25.7.0.dist-info}/licenses/LICENSE +0 -0
- {essreduce-25.5.3.dist-info → essreduce-25.7.0.dist-info}/top_level.txt +0 -0
|
@@ -6,46 +6,54 @@ Utilities for computing real neutron time-of-flight from chopper settings and
|
|
|
6
6
|
neutron time-of-arrival at the detectors.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from .
|
|
10
|
-
from .
|
|
11
|
-
from .
|
|
12
|
-
DetectorLtotal,
|
|
13
|
-
DetectorTofData,
|
|
9
|
+
from ..nexus.types import DiskChoppers
|
|
10
|
+
from .eto_to_tof import providers
|
|
11
|
+
from .lut import (
|
|
14
12
|
DistanceResolution,
|
|
15
13
|
LookupTableRelativeErrorThreshold,
|
|
16
14
|
LtotalRange,
|
|
17
|
-
|
|
18
|
-
MonitorTofData,
|
|
15
|
+
NumberOfSimulatedNeutrons,
|
|
19
16
|
PulsePeriod,
|
|
20
17
|
PulseStride,
|
|
21
|
-
PulseStrideOffset,
|
|
22
18
|
SimulationResults,
|
|
19
|
+
SimulationSeed,
|
|
20
|
+
SourcePosition,
|
|
21
|
+
TimeResolution,
|
|
22
|
+
TofLookupTableWorkflow,
|
|
23
|
+
simulate_chopper_cascade_using_tof,
|
|
24
|
+
)
|
|
25
|
+
from .types import (
|
|
26
|
+
DetectorLtotal,
|
|
27
|
+
DetectorTofData,
|
|
28
|
+
MonitorLtotal,
|
|
29
|
+
MonitorTofData,
|
|
30
|
+
PulseStrideOffset,
|
|
23
31
|
TimeOfFlightLookupTable,
|
|
24
32
|
TimeOfFlightLookupTableFilename,
|
|
25
|
-
TimeResolution,
|
|
26
33
|
)
|
|
27
|
-
from .workflow import GenericTofWorkflow
|
|
34
|
+
from .workflow import GenericTofWorkflow
|
|
28
35
|
|
|
29
36
|
__all__ = [
|
|
30
37
|
"DetectorLtotal",
|
|
31
38
|
"DetectorTofData",
|
|
32
|
-
"
|
|
39
|
+
"DiskChoppers",
|
|
33
40
|
"DistanceResolution",
|
|
34
41
|
"GenericTofWorkflow",
|
|
35
42
|
"LookupTableRelativeErrorThreshold",
|
|
36
43
|
"LtotalRange",
|
|
37
44
|
"MonitorLtotal",
|
|
38
45
|
"MonitorTofData",
|
|
39
|
-
"
|
|
46
|
+
"NumberOfSimulatedNeutrons",
|
|
40
47
|
"PulsePeriod",
|
|
41
48
|
"PulseStride",
|
|
42
49
|
"PulseStrideOffset",
|
|
43
50
|
"SimulationResults",
|
|
51
|
+
"SimulationSeed",
|
|
52
|
+
"SourcePosition",
|
|
44
53
|
"TimeOfFlightLookupTable",
|
|
45
54
|
"TimeOfFlightLookupTableFilename",
|
|
46
55
|
"TimeResolution",
|
|
47
|
-
"
|
|
48
|
-
"default_parameters",
|
|
56
|
+
"TofLookupTableWorkflow",
|
|
49
57
|
"providers",
|
|
50
|
-
"
|
|
58
|
+
"simulate_chopper_cascade_using_tof",
|
|
51
59
|
]
|
|
@@ -31,278 +31,13 @@ from .resample import rebin_strictly_increasing
|
|
|
31
31
|
from .types import (
|
|
32
32
|
DetectorLtotal,
|
|
33
33
|
DetectorTofData,
|
|
34
|
-
DistanceResolution,
|
|
35
|
-
LookupTableRelativeErrorThreshold,
|
|
36
|
-
LtotalRange,
|
|
37
34
|
MonitorLtotal,
|
|
38
35
|
MonitorTofData,
|
|
39
|
-
PulsePeriod,
|
|
40
|
-
PulseStride,
|
|
41
36
|
PulseStrideOffset,
|
|
42
|
-
SimulationResults,
|
|
43
37
|
TimeOfFlightLookupTable,
|
|
44
|
-
TimeResolution,
|
|
45
38
|
)
|
|
46
39
|
|
|
47
40
|
|
|
48
|
-
def _mask_large_uncertainty(table: sc.DataArray, error_threshold: float):
|
|
49
|
-
"""
|
|
50
|
-
Mask regions with large uncertainty with NaNs.
|
|
51
|
-
The values are modified in place in the input table.
|
|
52
|
-
|
|
53
|
-
Parameters
|
|
54
|
-
----------
|
|
55
|
-
table:
|
|
56
|
-
Lookup table with time-of-flight as a function of distance and time-of-arrival.
|
|
57
|
-
error_threshold:
|
|
58
|
-
Threshold for the relative standard deviation (coefficient of variation) of the
|
|
59
|
-
projected time-of-flight above which values are masked.
|
|
60
|
-
"""
|
|
61
|
-
# Finally, mask regions with large uncertainty with NaNs.
|
|
62
|
-
relative_error = sc.stddevs(table.data) / sc.values(table.data)
|
|
63
|
-
mask = relative_error > sc.scalar(error_threshold)
|
|
64
|
-
# Use numpy for indexing as table is 2D
|
|
65
|
-
table.values[mask.values] = np.nan
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _compute_mean_tof_in_distance_range(
|
|
69
|
-
simulation: SimulationResults,
|
|
70
|
-
distance_bins: sc.Variable,
|
|
71
|
-
time_bins: sc.Variable,
|
|
72
|
-
distance_unit: str,
|
|
73
|
-
time_unit: str,
|
|
74
|
-
frame_period: sc.Variable,
|
|
75
|
-
time_bins_half_width: sc.Variable,
|
|
76
|
-
) -> sc.DataArray:
|
|
77
|
-
"""
|
|
78
|
-
Compute the mean time-of-flight inside event_time_offset bins for a given range of
|
|
79
|
-
distances.
|
|
80
|
-
|
|
81
|
-
Parameters
|
|
82
|
-
----------
|
|
83
|
-
simulation:
|
|
84
|
-
Results of a time-of-flight simulation used to create a lookup table.
|
|
85
|
-
distance_bins:
|
|
86
|
-
Bin edges for the distance axis in the lookup table.
|
|
87
|
-
time_bins:
|
|
88
|
-
Bin edges for the event_time_offset axis in the lookup table.
|
|
89
|
-
distance_unit:
|
|
90
|
-
Unit of the distance axis.
|
|
91
|
-
time_unit:
|
|
92
|
-
Unit of the event_time_offset axis.
|
|
93
|
-
frame_period:
|
|
94
|
-
Period of the source pulses, i.e., time between consecutive pulse starts.
|
|
95
|
-
time_bins_half_width:
|
|
96
|
-
Half width of the time bins in the event_time_offset axis.
|
|
97
|
-
"""
|
|
98
|
-
simulation_distance = simulation.distance.to(unit=distance_unit)
|
|
99
|
-
distances = sc.midpoints(distance_bins)
|
|
100
|
-
# Compute arrival and flight times for all neutrons
|
|
101
|
-
toas = simulation.time_of_arrival + (distances / simulation.speed).to(
|
|
102
|
-
unit=time_unit, copy=False
|
|
103
|
-
)
|
|
104
|
-
dist = distances + simulation_distance
|
|
105
|
-
tofs = dist * (sc.constants.m_n / sc.constants.h) * simulation.wavelength
|
|
106
|
-
|
|
107
|
-
data = sc.DataArray(
|
|
108
|
-
data=sc.broadcast(simulation.weight, sizes=toas.sizes),
|
|
109
|
-
coords={
|
|
110
|
-
"toa": toas,
|
|
111
|
-
"tof": tofs.to(unit=time_unit, copy=False),
|
|
112
|
-
"distance": dist,
|
|
113
|
-
},
|
|
114
|
-
).flatten(to="event")
|
|
115
|
-
|
|
116
|
-
# Add the event_time_offset coordinate, wrapped to the frame_period
|
|
117
|
-
data.coords['event_time_offset'] = data.coords['toa'] % frame_period
|
|
118
|
-
|
|
119
|
-
# Because we staggered the mesh by half a bin width, we want the values above
|
|
120
|
-
# the last bin edge to wrap around to the first bin.
|
|
121
|
-
# Technically, those values should end up between -0.5*bin_width and 0, but
|
|
122
|
-
# a simple modulo also works here because even if they end up between 0 and
|
|
123
|
-
# 0.5*bin_width, we are (below) computing the mean between -0.5*bin_width and
|
|
124
|
-
# 0.5*bin_width and it yields the same result.
|
|
125
|
-
# data.coords['event_time_offset'] %= pulse_period - time_bins_half_width
|
|
126
|
-
data.coords['event_time_offset'] %= frame_period - time_bins_half_width
|
|
127
|
-
|
|
128
|
-
binned = data.bin(
|
|
129
|
-
distance=distance_bins + simulation_distance, event_time_offset=time_bins
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
# Weighted mean of tof inside each bin
|
|
133
|
-
mean_tof = (
|
|
134
|
-
binned.bins.data * binned.bins.coords["tof"]
|
|
135
|
-
).bins.sum() / binned.bins.sum()
|
|
136
|
-
# Compute the variance of the tofs to track regions with large uncertainty
|
|
137
|
-
variance = (
|
|
138
|
-
binned.bins.data * (binned.bins.coords["tof"] - mean_tof) ** 2
|
|
139
|
-
).bins.sum() / binned.bins.sum()
|
|
140
|
-
|
|
141
|
-
mean_tof.variances = variance.values
|
|
142
|
-
return mean_tof
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def compute_tof_lookup_table(
|
|
146
|
-
simulation: SimulationResults,
|
|
147
|
-
ltotal_range: LtotalRange,
|
|
148
|
-
distance_resolution: DistanceResolution,
|
|
149
|
-
time_resolution: TimeResolution,
|
|
150
|
-
pulse_period: PulsePeriod,
|
|
151
|
-
pulse_stride: PulseStride,
|
|
152
|
-
error_threshold: LookupTableRelativeErrorThreshold,
|
|
153
|
-
) -> TimeOfFlightLookupTable:
|
|
154
|
-
"""
|
|
155
|
-
Compute a lookup table for time-of-flight as a function of distance and
|
|
156
|
-
time-of-arrival.
|
|
157
|
-
|
|
158
|
-
Parameters
|
|
159
|
-
----------
|
|
160
|
-
simulation:
|
|
161
|
-
Results of a time-of-flight simulation used to create a lookup table.
|
|
162
|
-
The results should be a flat table with columns for time-of-arrival, speed,
|
|
163
|
-
wavelength, and weight.
|
|
164
|
-
ltotal_range:
|
|
165
|
-
Range of total flight path lengths from the source to the detector.
|
|
166
|
-
distance_resolution:
|
|
167
|
-
Resolution of the distance axis in the lookup table.
|
|
168
|
-
time_resolution:
|
|
169
|
-
Resolution of the time-of-arrival axis in the lookup table. Must be an integer.
|
|
170
|
-
pulse_period:
|
|
171
|
-
Period of the source pulses, i.e., time between consecutive pulse starts.
|
|
172
|
-
pulse_stride:
|
|
173
|
-
Stride of used pulses. Usually 1, but may be a small integer when
|
|
174
|
-
pulse-skipping.
|
|
175
|
-
error_threshold:
|
|
176
|
-
Threshold for the relative standard deviation (coefficient of variation) of the
|
|
177
|
-
projected time-of-flight above which values are masked.
|
|
178
|
-
|
|
179
|
-
Notes
|
|
180
|
-
-----
|
|
181
|
-
|
|
182
|
-
Below are some details about the binning and wrapping around frame period in the
|
|
183
|
-
time dimension.
|
|
184
|
-
|
|
185
|
-
We have some simulated ``toa`` (events) from a Tof/McStas simulation.
|
|
186
|
-
Those are absolute ``toa``, unwrapped.
|
|
187
|
-
First we compute the usual ``event_time_offset = toa % frame_period``.
|
|
188
|
-
|
|
189
|
-
Now, we want to ensure periodic boundaries. If we make a bin centered around 0,
|
|
190
|
-
and a bin centered around 71ms: the first bin will use events between 0 and
|
|
191
|
-
``0.5 * dt`` (where ``dt`` is the bin width).
|
|
192
|
-
The last bin will use events between ``frame_period - 0.5*dt`` and
|
|
193
|
-
``frame_period + 0.5 * dt``. So when we compute the mean inside those two bins,
|
|
194
|
-
they will not yield the same results.
|
|
195
|
-
It is as if the first bin is missing the events it should have between
|
|
196
|
-
``-0.5 * dt`` and 0 (because of the modulo we computed above).
|
|
197
|
-
|
|
198
|
-
To fix this, we do not make a last bin around 71ms (the bins stop at
|
|
199
|
-
``frame_period - 0.5*dt``). Instead, we compute modulo a second time,
|
|
200
|
-
but this time using ``event_time_offset %= (frame_period - 0.5*dt)``.
|
|
201
|
-
(we cannot directly do ``event_time_offset = toa % (frame_period - 0.5*dt)`` in a
|
|
202
|
-
single step because it would introduce a gradual shift,
|
|
203
|
-
as the pulse number increases).
|
|
204
|
-
|
|
205
|
-
This second modulo effectively takes all the events that would have gone in the
|
|
206
|
-
last bin (between ``frame_period - 0.5*dt`` and ``frame_period``) and puts them in
|
|
207
|
-
the first bin. Instead of placing them between ``-0.5*dt`` and 0,
|
|
208
|
-
it places them between 0 and ``0.5*dt``, but this does not really matter,
|
|
209
|
-
because we then take the mean inside the first bin.
|
|
210
|
-
Whether the events are on the left or right side of zero does not matter.
|
|
211
|
-
|
|
212
|
-
Finally, we make a copy of the left edge, and append it to the right of the table,
|
|
213
|
-
thus ensuring that the values on the right edge are strictly the same as on the
|
|
214
|
-
left edge.
|
|
215
|
-
"""
|
|
216
|
-
distance_unit = "m"
|
|
217
|
-
time_unit = simulation.time_of_arrival.unit
|
|
218
|
-
res = distance_resolution.to(unit=distance_unit)
|
|
219
|
-
pulse_period = pulse_period.to(unit=time_unit)
|
|
220
|
-
frame_period = pulse_period * pulse_stride
|
|
221
|
-
|
|
222
|
-
min_dist, max_dist = (
|
|
223
|
-
x.to(unit=distance_unit) - simulation.distance.to(unit=distance_unit)
|
|
224
|
-
for x in ltotal_range
|
|
225
|
-
)
|
|
226
|
-
# We need to bin the data below, to compute the weighted mean of the wavelength.
|
|
227
|
-
# This results in data with bin edges.
|
|
228
|
-
# However, the 2d interpolator expects bin centers.
|
|
229
|
-
# We want to give the 2d interpolator a table that covers the requested range,
|
|
230
|
-
# hence we need to extend the range by at least half a resolution in each direction.
|
|
231
|
-
# Then, we make the choice that the resolution in distance is the quantity that
|
|
232
|
-
# should be preserved. Because the difference between min and max distance is
|
|
233
|
-
# not necessarily an integer multiple of the resolution, we need to add a pad to
|
|
234
|
-
# ensure that the last bin is not cut off. We want the upper edge to be higher than
|
|
235
|
-
# the maximum distance, hence we pad with an additional 1.5 x resolution.
|
|
236
|
-
pad = 2.0 * res
|
|
237
|
-
distance_bins = sc.arange('distance', min_dist - pad, max_dist + pad, res)
|
|
238
|
-
|
|
239
|
-
# Create some time bins for event_time_offset.
|
|
240
|
-
# We want our final table to strictly cover the range [0, frame_period].
|
|
241
|
-
# However, binning the data associates mean values inside the bins to the bin
|
|
242
|
-
# centers. Instead, we stagger the mesh by half a bin width so we are computing
|
|
243
|
-
# values for the final mesh edges (the bilinear interpolation needs values on the
|
|
244
|
-
# edges/corners).
|
|
245
|
-
nbins = int(frame_period / time_resolution.to(unit=time_unit)) + 1
|
|
246
|
-
time_bins = sc.linspace(
|
|
247
|
-
'event_time_offset', 0.0, frame_period.value, nbins + 1, unit=pulse_period.unit
|
|
248
|
-
)
|
|
249
|
-
time_bins_half_width = 0.5 * (time_bins[1] - time_bins[0])
|
|
250
|
-
time_bins -= time_bins_half_width
|
|
251
|
-
|
|
252
|
-
# To avoid a too large RAM usage, we compute the table in chunks, and piece them
|
|
253
|
-
# together at the end.
|
|
254
|
-
ndist = len(distance_bins) - 1
|
|
255
|
-
max_size = 2e7
|
|
256
|
-
total_size = ndist * len(simulation.time_of_arrival)
|
|
257
|
-
nchunks = total_size / max_size
|
|
258
|
-
chunk_size = int(ndist / nchunks) + 1
|
|
259
|
-
pieces = []
|
|
260
|
-
for i in range(int(nchunks) + 1):
|
|
261
|
-
dist_edges = distance_bins[i * chunk_size : (i + 1) * chunk_size + 1]
|
|
262
|
-
|
|
263
|
-
pieces.append(
|
|
264
|
-
_compute_mean_tof_in_distance_range(
|
|
265
|
-
simulation=simulation,
|
|
266
|
-
distance_bins=dist_edges,
|
|
267
|
-
time_bins=time_bins,
|
|
268
|
-
distance_unit=distance_unit,
|
|
269
|
-
time_unit=time_unit,
|
|
270
|
-
frame_period=frame_period,
|
|
271
|
-
time_bins_half_width=time_bins_half_width,
|
|
272
|
-
)
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
table = sc.concat(pieces, 'distance')
|
|
276
|
-
table.coords["distance"] = sc.midpoints(table.coords["distance"])
|
|
277
|
-
table.coords["event_time_offset"] = sc.midpoints(table.coords["event_time_offset"])
|
|
278
|
-
|
|
279
|
-
# Copy the left edge to the right to create periodic boundary conditions
|
|
280
|
-
table = sc.DataArray(
|
|
281
|
-
data=sc.concat(
|
|
282
|
-
[table.data, table.data['event_time_offset', 0]], dim='event_time_offset'
|
|
283
|
-
),
|
|
284
|
-
coords={
|
|
285
|
-
"distance": table.coords["distance"],
|
|
286
|
-
"event_time_offset": sc.concat(
|
|
287
|
-
[table.coords["event_time_offset"], frame_period],
|
|
288
|
-
dim='event_time_offset',
|
|
289
|
-
),
|
|
290
|
-
"pulse_period": pulse_period,
|
|
291
|
-
"pulse_stride": sc.scalar(pulse_stride, unit=None),
|
|
292
|
-
"distance_resolution": table.coords["distance"][1]
|
|
293
|
-
- table.coords["distance"][0],
|
|
294
|
-
"time_resolution": table.coords["event_time_offset"][1]
|
|
295
|
-
- table.coords["event_time_offset"][0],
|
|
296
|
-
"error_threshold": sc.scalar(error_threshold),
|
|
297
|
-
},
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
# In-place masking for better performance
|
|
301
|
-
_mask_large_uncertainty(table, error_threshold)
|
|
302
|
-
|
|
303
|
-
return TimeOfFlightLookupTable(table)
|
|
304
|
-
|
|
305
|
-
|
|
306
41
|
class TofInterpolator:
|
|
307
42
|
def __init__(self, lookup: sc.DataArray, distance_unit: str, time_unit: str):
|
|
308
43
|
self._distance_unit = distance_unit
|
|
@@ -360,8 +95,8 @@ def _time_of_flight_data_histogram(
|
|
|
360
95
|
da: sc.DataArray, lookup: sc.DataArray, ltotal: sc.Variable
|
|
361
96
|
) -> sc.DataArray:
|
|
362
97
|
# In NeXus, 'time_of_flight' is the canonical name in NXmonitor, but in some files,
|
|
363
|
-
# it may be called 'tof'.
|
|
364
|
-
key = next(iter(set(da.coords.keys()) & {"time_of_flight", "tof"}))
|
|
98
|
+
# it may be called 'tof' or 'frame_time'.
|
|
99
|
+
key = next(iter(set(da.coords.keys()) & {"time_of_flight", "tof", "frame_time"}))
|
|
365
100
|
raw_eto = da.coords[key].to(dtype=float, copy=False)
|
|
366
101
|
eto_unit = raw_eto.unit
|
|
367
102
|
pulse_period = lookup.coords["pulse_period"].to(unit=eto_unit)
|
|
@@ -387,7 +122,9 @@ def _time_of_flight_data_histogram(
|
|
|
387
122
|
pulse_period=pulse_period,
|
|
388
123
|
)
|
|
389
124
|
|
|
390
|
-
return rebinned.assign_coords(tof=tofs)
|
|
125
|
+
return rebinned.assign_coords(tof=tofs).drop_coords(
|
|
126
|
+
list({key} & {"time_of_flight", "frame_time"})
|
|
127
|
+
)
|
|
391
128
|
|
|
392
129
|
|
|
393
130
|
def _guess_pulse_stride_offset(
|
|
@@ -529,7 +266,8 @@ def _time_of_flight_data_events(
|
|
|
529
266
|
|
|
530
267
|
parts = da.bins.constituents
|
|
531
268
|
parts["data"] = tofs
|
|
532
|
-
|
|
269
|
+
result = da.bins.assign_coords(tof=sc.bins(**parts, validate_indices=False))
|
|
270
|
+
return result.bins.drop_coords("event_time_offset")
|
|
533
271
|
|
|
534
272
|
|
|
535
273
|
def detector_ltotal_from_straight_line_approximation(
|
|
@@ -663,26 +401,11 @@ def monitor_time_of_flight_data(
|
|
|
663
401
|
)
|
|
664
402
|
|
|
665
403
|
|
|
666
|
-
def default_parameters() -> dict:
|
|
667
|
-
"""
|
|
668
|
-
Default parameters of the time-of-flight workflow.
|
|
669
|
-
"""
|
|
670
|
-
return {
|
|
671
|
-
PulsePeriod: 1.0 / sc.scalar(14.0, unit="Hz"),
|
|
672
|
-
PulseStride: 1,
|
|
673
|
-
PulseStrideOffset: None,
|
|
674
|
-
DistanceResolution: sc.scalar(0.1, unit="m"),
|
|
675
|
-
TimeResolution: sc.scalar(250.0, unit='us'),
|
|
676
|
-
LookupTableRelativeErrorThreshold: 0.1,
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
|
|
680
404
|
def providers() -> tuple[Callable]:
|
|
681
405
|
"""
|
|
682
406
|
Providers of the time-of-flight workflow.
|
|
683
407
|
"""
|
|
684
408
|
return (
|
|
685
|
-
compute_tof_lookup_table,
|
|
686
409
|
detector_time_of_flight_data,
|
|
687
410
|
monitor_time_of_flight_data,
|
|
688
411
|
detector_ltotal_from_straight_line_approximation,
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
|
|
3
|
+
"""
|
|
4
|
+
Utilities for computing time-of-flight lookup tables from neutron simulations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import NewType
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import sciline as sl
|
|
12
|
+
import scipp as sc
|
|
13
|
+
|
|
14
|
+
from ..nexus.types import DiskChoppers
|
|
15
|
+
from .types import TimeOfFlightLookupTable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SimulationResults:
|
|
20
|
+
"""
|
|
21
|
+
Results of a time-of-flight simulation used to create a lookup table.
|
|
22
|
+
|
|
23
|
+
The results (apart from ``distance``) should be flat lists (1d arrays) of length N
|
|
24
|
+
where N is the number of neutrons, containing the properties of the neutrons in the
|
|
25
|
+
simulation.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
time_of_arrival:
|
|
30
|
+
Time of arrival of the neutrons at the position where the events were recorded
|
|
31
|
+
(1d array of size N).
|
|
32
|
+
speed:
|
|
33
|
+
Speed of the neutrons, typically derived from the wavelength of the neutrons
|
|
34
|
+
(1d array of size N).
|
|
35
|
+
wavelength:
|
|
36
|
+
Wavelength of the neutrons (1d array of size N).
|
|
37
|
+
weight:
|
|
38
|
+
Weight/probability of the neutrons (1d array of size N).
|
|
39
|
+
distance:
|
|
40
|
+
Distance from the source to the position where the events were recorded
|
|
41
|
+
(single value; we assume all neutrons were recorded at the same position).
|
|
42
|
+
For a ``tof`` simulation, this is just the position of the detector where the
|
|
43
|
+
events are recorded. For a ``McStas`` simulation, this is the distance between
|
|
44
|
+
the source and the event monitor.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
time_of_arrival: sc.Variable
|
|
48
|
+
speed: sc.Variable
|
|
49
|
+
wavelength: sc.Variable
|
|
50
|
+
weight: sc.Variable
|
|
51
|
+
distance: sc.Variable
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
NumberOfSimulatedNeutrons = NewType("NumberOfSimulatedNeutrons", int)
|
|
55
|
+
"""
|
|
56
|
+
Number of neutrons simulated in the simulation that is used to create the lookup table.
|
|
57
|
+
This is typically a large number, e.g., 1e6 or 1e7.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
LtotalRange = NewType("LtotalRange", tuple[sc.Variable, sc.Variable])
|
|
61
|
+
"""
|
|
62
|
+
Range (min, max) of the total length of the flight path from the source to the detector.
|
|
63
|
+
This is used to create the lookup table to compute the neutron time-of-flight.
|
|
64
|
+
Note that the resulting table will extend slightly beyond this range, as the supplied
|
|
65
|
+
range is not necessarily a multiple of the distance resolution.
|
|
66
|
+
|
|
67
|
+
Note also that the range of total flight paths is supplied manually to the workflow
|
|
68
|
+
instead of being read from the input data, as it allows us to compute the expensive part
|
|
69
|
+
of the workflow in advance (the lookup table) and does not need to be repeated for each
|
|
70
|
+
run, or for new data coming in in the case of live data collection.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
DistanceResolution = NewType("DistanceResolution", sc.Variable)
|
|
74
|
+
"""
|
|
75
|
+
Step size of the distance axis in the lookup table.
|
|
76
|
+
Should be a single scalar value with a unit of length.
|
|
77
|
+
This is typically of the order of 1-10 cm.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
TimeResolution = NewType("TimeResolution", sc.Variable)
|
|
81
|
+
"""
|
|
82
|
+
Step size of the event_time_offset axis in the lookup table.
|
|
83
|
+
This is basically the 'time-of-flight' resolution of the detector.
|
|
84
|
+
Should be a single scalar value with a unit of time.
|
|
85
|
+
This is typically of the order of 0.1-0.5 ms.
|
|
86
|
+
|
|
87
|
+
Since the event_time_offset range needs to span exactly one pulse period, the final
|
|
88
|
+
resolution in the lookup table will be at least the supplied value here, but may be
|
|
89
|
+
smaller if the pulse period is not an integer multiple of the time resolution.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
LookupTableRelativeErrorThreshold = NewType("LookupTableRelativeErrorThreshold", float)
|
|
94
|
+
"""
|
|
95
|
+
Threshold for the relative standard deviation (coefficient of variation) of the
|
|
96
|
+
projected time-of-flight above which values are masked.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
PulsePeriod = NewType("PulsePeriod", sc.Variable)
|
|
100
|
+
"""
|
|
101
|
+
Period of the source pulses, i.e., time between consecutive pulse starts.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
PulseStride = NewType("PulseStride", int)
|
|
105
|
+
"""
|
|
106
|
+
Stride of used pulses. Usually 1, but may be a small integer when pulse-skipping.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
SourcePosition = NewType("SourcePosition", sc.Variable)
|
|
110
|
+
"""
|
|
111
|
+
Position of the neutron source in the coordinate system of the choppers.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
SimulationSeed = NewType("SimulationSeed", int | None)
|
|
115
|
+
"""Seed for the random number generator used in the simulation.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
SimulationFacility = NewType("SimulationFacility", str)
|
|
119
|
+
"""
|
|
120
|
+
Facility where the experiment is performed, e.g., 'ess'.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _mask_large_uncertainty(table: sc.DataArray, error_threshold: float):
|
|
125
|
+
"""
|
|
126
|
+
Mask regions with large uncertainty with NaNs.
|
|
127
|
+
The values are modified in place in the input table.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
table:
|
|
132
|
+
Lookup table with time-of-flight as a function of distance and time-of-arrival.
|
|
133
|
+
error_threshold:
|
|
134
|
+
Threshold for the relative standard deviation (coefficient of variation) of the
|
|
135
|
+
projected time-of-flight above which values are masked.
|
|
136
|
+
"""
|
|
137
|
+
# Finally, mask regions with large uncertainty with NaNs.
|
|
138
|
+
relative_error = sc.stddevs(table.data) / sc.values(table.data)
|
|
139
|
+
mask = relative_error > sc.scalar(error_threshold)
|
|
140
|
+
# Use numpy for indexing as table is 2D
|
|
141
|
+
table.values[mask.values] = np.nan
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _compute_mean_tof_in_distance_range(
|
|
145
|
+
simulation: SimulationResults,
|
|
146
|
+
distance_bins: sc.Variable,
|
|
147
|
+
time_bins: sc.Variable,
|
|
148
|
+
distance_unit: str,
|
|
149
|
+
time_unit: str,
|
|
150
|
+
frame_period: sc.Variable,
|
|
151
|
+
time_bins_half_width: sc.Variable,
|
|
152
|
+
) -> sc.DataArray:
|
|
153
|
+
"""
|
|
154
|
+
Compute the mean time-of-flight inside event_time_offset bins for a given range of
|
|
155
|
+
distances.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
simulation:
|
|
160
|
+
Results of a time-of-flight simulation used to create a lookup table.
|
|
161
|
+
distance_bins:
|
|
162
|
+
Bin edges for the distance axis in the lookup table.
|
|
163
|
+
time_bins:
|
|
164
|
+
Bin edges for the event_time_offset axis in the lookup table.
|
|
165
|
+
distance_unit:
|
|
166
|
+
Unit of the distance axis.
|
|
167
|
+
time_unit:
|
|
168
|
+
Unit of the event_time_offset axis.
|
|
169
|
+
frame_period:
|
|
170
|
+
Period of the source pulses, i.e., time between consecutive pulse starts.
|
|
171
|
+
time_bins_half_width:
|
|
172
|
+
Half width of the time bins in the event_time_offset axis.
|
|
173
|
+
"""
|
|
174
|
+
simulation_distance = simulation.distance.to(unit=distance_unit)
|
|
175
|
+
distances = sc.midpoints(distance_bins)
|
|
176
|
+
# Compute arrival and flight times for all neutrons
|
|
177
|
+
toas = simulation.time_of_arrival + (distances / simulation.speed).to(
|
|
178
|
+
unit=time_unit, copy=False
|
|
179
|
+
)
|
|
180
|
+
dist = distances + simulation_distance
|
|
181
|
+
tofs = dist * (sc.constants.m_n / sc.constants.h) * simulation.wavelength
|
|
182
|
+
|
|
183
|
+
data = sc.DataArray(
|
|
184
|
+
data=sc.broadcast(simulation.weight, sizes=toas.sizes),
|
|
185
|
+
coords={
|
|
186
|
+
"toa": toas,
|
|
187
|
+
"tof": tofs.to(unit=time_unit, copy=False),
|
|
188
|
+
"distance": dist,
|
|
189
|
+
},
|
|
190
|
+
).flatten(to="event")
|
|
191
|
+
|
|
192
|
+
# Add the event_time_offset coordinate, wrapped to the frame_period
|
|
193
|
+
data.coords['event_time_offset'] = data.coords['toa'] % frame_period
|
|
194
|
+
|
|
195
|
+
# Because we staggered the mesh by half a bin width, we want the values above
|
|
196
|
+
# the last bin edge to wrap around to the first bin.
|
|
197
|
+
# Technically, those values should end up between -0.5*bin_width and 0, but
|
|
198
|
+
# a simple modulo also works here because even if they end up between 0 and
|
|
199
|
+
# 0.5*bin_width, we are (below) computing the mean between -0.5*bin_width and
|
|
200
|
+
# 0.5*bin_width and it yields the same result.
|
|
201
|
+
# data.coords['event_time_offset'] %= pulse_period - time_bins_half_width
|
|
202
|
+
data.coords['event_time_offset'] %= frame_period - time_bins_half_width
|
|
203
|
+
|
|
204
|
+
binned = data.bin(
|
|
205
|
+
distance=distance_bins + simulation_distance, event_time_offset=time_bins
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Weighted mean of tof inside each bin
|
|
209
|
+
mean_tof = (
|
|
210
|
+
binned.bins.data * binned.bins.coords["tof"]
|
|
211
|
+
).bins.sum() / binned.bins.sum()
|
|
212
|
+
# Compute the variance of the tofs to track regions with large uncertainty
|
|
213
|
+
variance = (
|
|
214
|
+
binned.bins.data * (binned.bins.coords["tof"] - mean_tof) ** 2
|
|
215
|
+
).bins.sum() / binned.bins.sum()
|
|
216
|
+
|
|
217
|
+
mean_tof.variances = variance.values
|
|
218
|
+
return mean_tof
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def make_tof_lookup_table(
|
|
222
|
+
simulation: SimulationResults,
|
|
223
|
+
ltotal_range: LtotalRange,
|
|
224
|
+
distance_resolution: DistanceResolution,
|
|
225
|
+
time_resolution: TimeResolution,
|
|
226
|
+
pulse_period: PulsePeriod,
|
|
227
|
+
pulse_stride: PulseStride,
|
|
228
|
+
error_threshold: LookupTableRelativeErrorThreshold,
|
|
229
|
+
) -> TimeOfFlightLookupTable:
|
|
230
|
+
"""
|
|
231
|
+
Compute a lookup table for time-of-flight as a function of distance and
|
|
232
|
+
time-of-arrival.
|
|
233
|
+
|
|
234
|
+
Parameters
|
|
235
|
+
----------
|
|
236
|
+
simulation:
|
|
237
|
+
Results of a time-of-flight simulation used to create a lookup table.
|
|
238
|
+
The results should be a flat table with columns for time-of-arrival, speed,
|
|
239
|
+
wavelength, and weight.
|
|
240
|
+
ltotal_range:
|
|
241
|
+
Range of total flight path lengths from the source to the detector.
|
|
242
|
+
distance_resolution:
|
|
243
|
+
Resolution of the distance axis in the lookup table.
|
|
244
|
+
time_resolution:
|
|
245
|
+
Resolution of the time-of-arrival axis in the lookup table. Must be an integer.
|
|
246
|
+
pulse_period:
|
|
247
|
+
Period of the source pulses, i.e., time between consecutive pulse starts.
|
|
248
|
+
pulse_stride:
|
|
249
|
+
Stride of used pulses. Usually 1, but may be a small integer when
|
|
250
|
+
pulse-skipping.
|
|
251
|
+
error_threshold:
|
|
252
|
+
Threshold for the relative standard deviation (coefficient of variation) of the
|
|
253
|
+
projected time-of-flight above which values are masked.
|
|
254
|
+
|
|
255
|
+
Notes
|
|
256
|
+
-----
|
|
257
|
+
|
|
258
|
+
Below are some details about the binning and wrapping around frame period in the
|
|
259
|
+
time dimension.
|
|
260
|
+
|
|
261
|
+
We have some simulated ``toa`` (events) from a Tof/McStas simulation.
|
|
262
|
+
Those are absolute ``toa``, unwrapped.
|
|
263
|
+
First we compute the usual ``event_time_offset = toa % frame_period``.
|
|
264
|
+
|
|
265
|
+
Now, we want to ensure periodic boundaries. If we make a bin centered around 0,
|
|
266
|
+
and a bin centered around 71ms: the first bin will use events between 0 and
|
|
267
|
+
``0.5 * dt`` (where ``dt`` is the bin width).
|
|
268
|
+
The last bin will use events between ``frame_period - 0.5*dt`` and
|
|
269
|
+
``frame_period + 0.5 * dt``. So when we compute the mean inside those two bins,
|
|
270
|
+
they will not yield the same results.
|
|
271
|
+
It is as if the first bin is missing the events it should have between
|
|
272
|
+
``-0.5 * dt`` and 0 (because of the modulo we computed above).
|
|
273
|
+
|
|
274
|
+
To fix this, we do not make a last bin around 71ms (the bins stop at
|
|
275
|
+
``frame_period - 0.5*dt``). Instead, we compute modulo a second time,
|
|
276
|
+
but this time using ``event_time_offset %= (frame_period - 0.5*dt)``.
|
|
277
|
+
(we cannot directly do ``event_time_offset = toa % (frame_period - 0.5*dt)`` in a
|
|
278
|
+
single step because it would introduce a gradual shift,
|
|
279
|
+
as the pulse number increases).
|
|
280
|
+
|
|
281
|
+
This second modulo effectively takes all the events that would have gone in the
|
|
282
|
+
last bin (between ``frame_period - 0.5*dt`` and ``frame_period``) and puts them in
|
|
283
|
+
the first bin. Instead of placing them between ``-0.5*dt`` and 0,
|
|
284
|
+
it places them between 0 and ``0.5*dt``, but this does not really matter,
|
|
285
|
+
because we then take the mean inside the first bin.
|
|
286
|
+
Whether the events are on the left or right side of zero does not matter.
|
|
287
|
+
|
|
288
|
+
Finally, we make a copy of the left edge, and append it to the right of the table,
|
|
289
|
+
thus ensuring that the values on the right edge are strictly the same as on the
|
|
290
|
+
left edge.
|
|
291
|
+
"""
|
|
292
|
+
distance_unit = "m"
|
|
293
|
+
time_unit = simulation.time_of_arrival.unit
|
|
294
|
+
res = distance_resolution.to(unit=distance_unit)
|
|
295
|
+
pulse_period = pulse_period.to(unit=time_unit)
|
|
296
|
+
frame_period = pulse_period * pulse_stride
|
|
297
|
+
|
|
298
|
+
min_dist, max_dist = (
|
|
299
|
+
x.to(unit=distance_unit) - simulation.distance.to(unit=distance_unit)
|
|
300
|
+
for x in ltotal_range
|
|
301
|
+
)
|
|
302
|
+
# We need to bin the data below, to compute the weighted mean of the wavelength.
|
|
303
|
+
# This results in data with bin edges.
|
|
304
|
+
# However, the 2d interpolator expects bin centers.
|
|
305
|
+
# We want to give the 2d interpolator a table that covers the requested range,
|
|
306
|
+
# hence we need to extend the range by at least half a resolution in each direction.
|
|
307
|
+
# Then, we make the choice that the resolution in distance is the quantity that
|
|
308
|
+
# should be preserved. Because the difference between min and max distance is
|
|
309
|
+
# not necessarily an integer multiple of the resolution, we need to add a pad to
|
|
310
|
+
# ensure that the last bin is not cut off. We want the upper edge to be higher than
|
|
311
|
+
# the maximum distance, hence we pad with an additional 1.5 x resolution.
|
|
312
|
+
pad = 2.0 * res
|
|
313
|
+
distance_bins = sc.arange('distance', min_dist - pad, max_dist + pad, res)
|
|
314
|
+
|
|
315
|
+
# Create some time bins for event_time_offset.
|
|
316
|
+
# We want our final table to strictly cover the range [0, frame_period].
|
|
317
|
+
# However, binning the data associates mean values inside the bins to the bin
|
|
318
|
+
# centers. Instead, we stagger the mesh by half a bin width so we are computing
|
|
319
|
+
# values for the final mesh edges (the bilinear interpolation needs values on the
|
|
320
|
+
# edges/corners).
|
|
321
|
+
nbins = int(frame_period / time_resolution.to(unit=time_unit)) + 1
|
|
322
|
+
time_bins = sc.linspace(
|
|
323
|
+
'event_time_offset', 0.0, frame_period.value, nbins + 1, unit=pulse_period.unit
|
|
324
|
+
)
|
|
325
|
+
time_bins_half_width = 0.5 * (time_bins[1] - time_bins[0])
|
|
326
|
+
time_bins -= time_bins_half_width
|
|
327
|
+
|
|
328
|
+
# To avoid a too large RAM usage, we compute the table in chunks, and piece them
|
|
329
|
+
# together at the end.
|
|
330
|
+
ndist = len(distance_bins) - 1
|
|
331
|
+
max_size = 2e7
|
|
332
|
+
total_size = ndist * len(simulation.time_of_arrival)
|
|
333
|
+
nchunks = total_size / max_size
|
|
334
|
+
chunk_size = int(ndist / nchunks) + 1
|
|
335
|
+
pieces = []
|
|
336
|
+
for i in range(int(nchunks) + 1):
|
|
337
|
+
dist_edges = distance_bins[i * chunk_size : (i + 1) * chunk_size + 1]
|
|
338
|
+
|
|
339
|
+
pieces.append(
|
|
340
|
+
_compute_mean_tof_in_distance_range(
|
|
341
|
+
simulation=simulation,
|
|
342
|
+
distance_bins=dist_edges,
|
|
343
|
+
time_bins=time_bins,
|
|
344
|
+
distance_unit=distance_unit,
|
|
345
|
+
time_unit=time_unit,
|
|
346
|
+
frame_period=frame_period,
|
|
347
|
+
time_bins_half_width=time_bins_half_width,
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
table = sc.concat(pieces, 'distance')
|
|
352
|
+
table.coords["distance"] = sc.midpoints(table.coords["distance"])
|
|
353
|
+
table.coords["event_time_offset"] = sc.midpoints(table.coords["event_time_offset"])
|
|
354
|
+
|
|
355
|
+
# Copy the left edge to the right to create periodic boundary conditions
|
|
356
|
+
table = sc.DataArray(
|
|
357
|
+
data=sc.concat(
|
|
358
|
+
[table.data, table.data['event_time_offset', 0]], dim='event_time_offset'
|
|
359
|
+
),
|
|
360
|
+
coords={
|
|
361
|
+
"distance": table.coords["distance"],
|
|
362
|
+
"event_time_offset": sc.concat(
|
|
363
|
+
[table.coords["event_time_offset"], frame_period],
|
|
364
|
+
dim='event_time_offset',
|
|
365
|
+
),
|
|
366
|
+
"pulse_period": pulse_period,
|
|
367
|
+
"pulse_stride": sc.scalar(pulse_stride, unit=None),
|
|
368
|
+
"distance_resolution": table.coords["distance"][1]
|
|
369
|
+
- table.coords["distance"][0],
|
|
370
|
+
"time_resolution": table.coords["event_time_offset"][1]
|
|
371
|
+
- table.coords["event_time_offset"][0],
|
|
372
|
+
"error_threshold": sc.scalar(error_threshold),
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# In-place masking for better performance
|
|
377
|
+
_mask_large_uncertainty(table, error_threshold)
|
|
378
|
+
|
|
379
|
+
return TimeOfFlightLookupTable(table)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def simulate_chopper_cascade_using_tof(
|
|
383
|
+
choppers: DiskChoppers,
|
|
384
|
+
source_position: SourcePosition,
|
|
385
|
+
neutrons: NumberOfSimulatedNeutrons,
|
|
386
|
+
pulse_stride: PulseStride,
|
|
387
|
+
seed: SimulationSeed,
|
|
388
|
+
facility: SimulationFacility,
|
|
389
|
+
) -> SimulationResults:
|
|
390
|
+
"""
|
|
391
|
+
Simulate a pulse of neutrons propagating through a chopper cascade using the
|
|
392
|
+
``tof`` package (https://tof.readthedocs.io).
|
|
393
|
+
|
|
394
|
+
Parameters
|
|
395
|
+
----------
|
|
396
|
+
choppers:
|
|
397
|
+
A dict of DiskChopper objects representing the choppers in the beamline. See
|
|
398
|
+
https://scipp.github.io/scippneutron/user-guide/chopper/processing-nexus-choppers.html#Build-DiskChopper
|
|
399
|
+
for more information.
|
|
400
|
+
source_position:
|
|
401
|
+
A scalar variable with ``dtype=vector3`` that defines the source position.
|
|
402
|
+
Must be in the same coordinate system as the choppers' axle positions.
|
|
403
|
+
neutrons:
|
|
404
|
+
Number of neutrons to simulate.
|
|
405
|
+
pulse_stride:
|
|
406
|
+
The pulse strinde; we need to simulate at least enough pulses to cover the
|
|
407
|
+
requested stride.
|
|
408
|
+
seed:
|
|
409
|
+
Seed for the random number generator used in the simulation.
|
|
410
|
+
facility:
|
|
411
|
+
Facility where the experiment is performed.
|
|
412
|
+
"""
|
|
413
|
+
import tof
|
|
414
|
+
|
|
415
|
+
tof_choppers = [
|
|
416
|
+
tof.Chopper(
|
|
417
|
+
frequency=abs(ch.frequency),
|
|
418
|
+
direction=tof.AntiClockwise
|
|
419
|
+
if (ch.frequency.value > 0.0)
|
|
420
|
+
else tof.Clockwise,
|
|
421
|
+
open=ch.slit_begin,
|
|
422
|
+
close=ch.slit_end,
|
|
423
|
+
phase=abs(ch.phase),
|
|
424
|
+
distance=sc.norm(
|
|
425
|
+
ch.axle_position - source_position.to(unit=ch.axle_position.unit)
|
|
426
|
+
),
|
|
427
|
+
name=name,
|
|
428
|
+
)
|
|
429
|
+
for name, ch in choppers.items()
|
|
430
|
+
]
|
|
431
|
+
source = tof.Source(
|
|
432
|
+
facility=facility, neutrons=neutrons, pulses=pulse_stride, seed=seed
|
|
433
|
+
)
|
|
434
|
+
if not tof_choppers:
|
|
435
|
+
events = source.data.squeeze().flatten(to='event')
|
|
436
|
+
return SimulationResults(
|
|
437
|
+
time_of_arrival=events.coords["birth_time"],
|
|
438
|
+
speed=events.coords["speed"],
|
|
439
|
+
wavelength=events.coords["wavelength"],
|
|
440
|
+
weight=events.data,
|
|
441
|
+
distance=0.0 * sc.units.m,
|
|
442
|
+
)
|
|
443
|
+
model = tof.Model(source=source, choppers=tof_choppers)
|
|
444
|
+
results = model.run()
|
|
445
|
+
# Find name of the furthest chopper in tof_choppers
|
|
446
|
+
furthest_chopper = max(tof_choppers, key=lambda c: c.distance)
|
|
447
|
+
events = results[furthest_chopper.name].data.squeeze().flatten(to='event')
|
|
448
|
+
events = events[
|
|
449
|
+
~(events.masks["blocked_by_others"] | events.masks["blocked_by_me"])
|
|
450
|
+
]
|
|
451
|
+
return SimulationResults(
|
|
452
|
+
time_of_arrival=events.coords["toa"],
|
|
453
|
+
speed=events.coords["speed"],
|
|
454
|
+
wavelength=events.coords["wavelength"],
|
|
455
|
+
weight=events.data,
|
|
456
|
+
distance=furthest_chopper.distance,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def TofLookupTableWorkflow():
|
|
461
|
+
"""
|
|
462
|
+
Create a workflow for computing a time-of-flight lookup table from a
|
|
463
|
+
simulation of neutrons propagating through a chopper cascade.
|
|
464
|
+
"""
|
|
465
|
+
wf = sl.Pipeline(
|
|
466
|
+
(make_tof_lookup_table, simulate_chopper_cascade_using_tof),
|
|
467
|
+
params={
|
|
468
|
+
PulsePeriod: 1.0 / sc.scalar(14.0, unit="Hz"),
|
|
469
|
+
PulseStride: 1,
|
|
470
|
+
DistanceResolution: sc.scalar(0.1, unit="m"),
|
|
471
|
+
TimeResolution: sc.scalar(250.0, unit='us'),
|
|
472
|
+
LookupTableRelativeErrorThreshold: 0.1,
|
|
473
|
+
NumberOfSimulatedNeutrons: 1_000_000,
|
|
474
|
+
SimulationSeed: None,
|
|
475
|
+
SimulationFacility: 'ess',
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
return wf
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
2
|
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
|
|
3
3
|
|
|
4
|
-
from dataclasses import dataclass
|
|
5
4
|
from typing import NewType
|
|
6
5
|
|
|
7
6
|
import sciline as sl
|
|
@@ -9,81 +8,6 @@ import scipp as sc
|
|
|
9
8
|
|
|
10
9
|
from ..nexus.types import MonitorType, RunType
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
@dataclass
|
|
14
|
-
class SimulationResults:
|
|
15
|
-
"""
|
|
16
|
-
Results of a time-of-flight simulation used to create a lookup table.
|
|
17
|
-
|
|
18
|
-
The results (apart from ``distance``) should be flat lists (1d arrays) of length N
|
|
19
|
-
where N is the number of neutrons, containing the properties of the neutrons in the
|
|
20
|
-
simulation.
|
|
21
|
-
|
|
22
|
-
Parameters
|
|
23
|
-
----------
|
|
24
|
-
time_of_arrival:
|
|
25
|
-
Time of arrival of the neutrons at the position where the events were recorded
|
|
26
|
-
(1d array of size N).
|
|
27
|
-
speed:
|
|
28
|
-
Speed of the neutrons, typically derived from the wavelength of the neutrons
|
|
29
|
-
(1d array of size N).
|
|
30
|
-
wavelength:
|
|
31
|
-
Wavelength of the neutrons (1d array of size N).
|
|
32
|
-
weight:
|
|
33
|
-
Weight/probability of the neutrons (1d array of size N).
|
|
34
|
-
distance:
|
|
35
|
-
Distance from the source to the position where the events were recorded
|
|
36
|
-
(single value; we assume all neutrons were recorded at the same position).
|
|
37
|
-
For a ``tof`` simulation, this is just the position of the detector where the
|
|
38
|
-
events are recorded. For a ``McStas`` simulation, this is the distance between
|
|
39
|
-
the source and the event monitor.
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
time_of_arrival: sc.Variable
|
|
43
|
-
speed: sc.Variable
|
|
44
|
-
wavelength: sc.Variable
|
|
45
|
-
weight: sc.Variable
|
|
46
|
-
distance: sc.Variable
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
NumberOfSimulatedNeutrons = NewType("NumberOfSimulatedNeutrons", int)
|
|
50
|
-
"""
|
|
51
|
-
Number of neutrons simulated in the simulation that is used to create the lookup table.
|
|
52
|
-
This is typically a large number, e.g., 1e6 or 1e7.
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
LtotalRange = NewType("LtotalRange", tuple[sc.Variable, sc.Variable])
|
|
56
|
-
"""
|
|
57
|
-
Range (min, max) of the total length of the flight path from the source to the detector.
|
|
58
|
-
This is used to create the lookup table to compute the neutron time-of-flight.
|
|
59
|
-
Note that the resulting table will extend slightly beyond this range, as the supplied
|
|
60
|
-
range is not necessarily a multiple of the distance resolution.
|
|
61
|
-
|
|
62
|
-
Note also that the range of total flight paths is supplied manually to the workflow
|
|
63
|
-
instead of being read from the input data, as it allows us to compute the expensive part
|
|
64
|
-
of the workflow in advance (the lookup table) and does not need to be repeated for each
|
|
65
|
-
run, or for new data coming in in the case of live data collection.
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
DistanceResolution = NewType("DistanceResolution", sc.Variable)
|
|
69
|
-
"""
|
|
70
|
-
Step size of the distance axis in the lookup table.
|
|
71
|
-
Should be a single scalar value with a unit of length.
|
|
72
|
-
This is typically of the order of 1-10 cm.
|
|
73
|
-
"""
|
|
74
|
-
|
|
75
|
-
TimeResolution = NewType("TimeResolution", sc.Variable)
|
|
76
|
-
"""
|
|
77
|
-
Step size of the event_time_offset axis in the lookup table.
|
|
78
|
-
This is basically the 'time-of-flight' resolution of the detector.
|
|
79
|
-
Should be a single scalar value with a unit of time.
|
|
80
|
-
This is typically of the order of 0.1-0.5 ms.
|
|
81
|
-
|
|
82
|
-
Since the event_time_offset range needs to span exactly one pulse period, the final
|
|
83
|
-
resolution in the lookup table will be at least the supplied value here, but may be
|
|
84
|
-
smaller if the pulse period is not an integer multiple of the time resolution.
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
11
|
TimeOfFlightLookupTableFilename = NewType("TimeOfFlightLookupTableFilename", str)
|
|
88
12
|
"""Filename of the time-of-flight lookup table."""
|
|
89
13
|
|
|
@@ -93,21 +17,6 @@ TimeOfFlightLookupTable = NewType("TimeOfFlightLookupTable", sc.DataArray)
|
|
|
93
17
|
Lookup table giving time-of-flight as a function of distance and time of arrival.
|
|
94
18
|
"""
|
|
95
19
|
|
|
96
|
-
LookupTableRelativeErrorThreshold = NewType("LookupTableRelativeErrorThreshold", float)
|
|
97
|
-
"""
|
|
98
|
-
Threshold for the relative standard deviation (coefficient of variation) of the
|
|
99
|
-
projected time-of-flight above which values are masked.
|
|
100
|
-
"""
|
|
101
|
-
|
|
102
|
-
PulsePeriod = NewType("PulsePeriod", sc.Variable)
|
|
103
|
-
"""
|
|
104
|
-
Period of the source pulses, i.e., time between consecutive pulse starts.
|
|
105
|
-
"""
|
|
106
|
-
|
|
107
|
-
PulseStride = NewType("PulseStride", int)
|
|
108
|
-
"""
|
|
109
|
-
Stride of used pulses. Usually 1, but may be a small integer when pulse-skipping.
|
|
110
|
-
"""
|
|
111
20
|
|
|
112
21
|
PulseStrideOffset = NewType("PulseStrideOffset", int | None)
|
|
113
22
|
"""
|
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
2
|
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
|
|
3
3
|
from collections.abc import Iterable
|
|
4
|
-
from enum import Enum, auto
|
|
5
4
|
|
|
6
5
|
import sciline
|
|
7
6
|
import scipp as sc
|
|
8
7
|
|
|
9
8
|
from ..nexus import GenericNeXusWorkflow
|
|
10
|
-
from . import eto_to_tof
|
|
11
|
-
from .types import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
FILE = auto() # From file
|
|
18
|
-
TOF = auto() # Computed with 'tof' package from chopper settings
|
|
19
|
-
MCSTAS = auto() # McStas simulation (not implemented yet)
|
|
9
|
+
from . import eto_to_tof
|
|
10
|
+
from .types import (
|
|
11
|
+
PulseStrideOffset,
|
|
12
|
+
TimeOfFlightLookupTable,
|
|
13
|
+
TimeOfFlightLookupTableFilename,
|
|
14
|
+
)
|
|
20
15
|
|
|
21
16
|
|
|
22
17
|
def load_tof_lookup_table(
|
|
@@ -29,7 +24,6 @@ def GenericTofWorkflow(
|
|
|
29
24
|
*,
|
|
30
25
|
run_types: Iterable[sciline.typing.Key],
|
|
31
26
|
monitor_types: Iterable[sciline.typing.Key],
|
|
32
|
-
tof_lut_provider: TofLutProvider = TofLutProvider.FILE,
|
|
33
27
|
) -> sciline.Pipeline:
|
|
34
28
|
"""
|
|
35
29
|
Generic workflow for computing the neutron time-of-flight for detector and monitor
|
|
@@ -59,11 +53,6 @@ def GenericTofWorkflow(
|
|
|
59
53
|
List of monitor types to include in the workflow.
|
|
60
54
|
Constrains the possible values of :class:`ess.reduce.nexus.types.MonitorType`
|
|
61
55
|
and :class:`ess.reduce.nexus.types.Component`.
|
|
62
|
-
tof_lut_provider:
|
|
63
|
-
Specifies how the time-of-flight lookup table is provided:
|
|
64
|
-
- FILE: Read from a file
|
|
65
|
-
- TOF: Computed from chopper settings using the 'tof' package
|
|
66
|
-
- MCSTAS: From McStas simulation (not implemented yet)
|
|
67
56
|
|
|
68
57
|
Returns
|
|
69
58
|
-------
|
|
@@ -75,16 +64,9 @@ def GenericTofWorkflow(
|
|
|
75
64
|
for provider in eto_to_tof.providers():
|
|
76
65
|
wf.insert(provider)
|
|
77
66
|
|
|
78
|
-
|
|
79
|
-
wf.insert(load_tof_lookup_table)
|
|
80
|
-
else:
|
|
81
|
-
wf.insert(eto_to_tof.compute_tof_lookup_table)
|
|
82
|
-
if tof_lut_provider == TofLutProvider.TOF:
|
|
83
|
-
wf.insert(simulation.simulate_chopper_cascade_using_tof)
|
|
84
|
-
if tof_lut_provider == TofLutProvider.MCSTAS:
|
|
85
|
-
raise NotImplementedError("McStas simulation not implemented yet")
|
|
67
|
+
wf.insert(load_tof_lookup_table)
|
|
86
68
|
|
|
87
|
-
|
|
88
|
-
|
|
69
|
+
# Default parameters
|
|
70
|
+
wf[PulseStrideOffset] = None
|
|
89
71
|
|
|
90
72
|
return wf
|
|
@@ -18,15 +18,15 @@ ess/reduce/nexus/json_nexus.py,sha256=QrVc0p424nZ5dHX9gebAJppTw6lGZq9404P_OFl1gi
|
|
|
18
18
|
ess/reduce/nexus/types.py,sha256=DE82JnbgpTlQnt7UN2a2Gur2N9QupV3CDL9j4Iy4lsE,9178
|
|
19
19
|
ess/reduce/nexus/workflow.py,sha256=Ytt80-muk5EiXmip890ahb_m5DQqlTGRQUyaTVXRNzo,24568
|
|
20
20
|
ess/reduce/scripts/grow_nexus.py,sha256=hET3h06M0xlJd62E3palNLFvJMyNax2kK4XyJcOhl-I,3387
|
|
21
|
-
ess/reduce/time_of_flight/__init__.py,sha256=
|
|
22
|
-
ess/reduce/time_of_flight/eto_to_tof.py,sha256=
|
|
21
|
+
ess/reduce/time_of_flight/__init__.py,sha256=bhteT_xvkRYBh_oeJZTzCtiPg0WSW_TRrtm_xzGbzw4,1441
|
|
22
|
+
ess/reduce/time_of_flight/eto_to_tof.py,sha256=pkxtj1Gg0b2aF9Ryr2vL8PeL8RdujuzWbcp1L4TMghU,14712
|
|
23
23
|
ess/reduce/time_of_flight/fakes.py,sha256=0gtbSX3ZQilaM4ZP5dMr3fqbnhpyoVsZX2YEb8GgREE,4489
|
|
24
24
|
ess/reduce/time_of_flight/interpolator_numba.py,sha256=wh2YS3j2rOu30v1Ok3xNHcwS7t8eEtZyZvbfXOCtgrQ,3835
|
|
25
25
|
ess/reduce/time_of_flight/interpolator_scipy.py,sha256=_InoAPuMm2qhJKZQBAHOGRFqtvvuQ8TStoN7j_YgS4M,1853
|
|
26
|
+
ess/reduce/time_of_flight/lut.py,sha256=ylZbnf6LeOUzkBkUbLCudeqVfh-Gtf9M-z8PS-l9Db4,18719
|
|
26
27
|
ess/reduce/time_of_flight/resample.py,sha256=Opmi-JA4zNH725l9VB99U4O9UlM37f5ACTCGtwBcows,3718
|
|
27
|
-
ess/reduce/time_of_flight/
|
|
28
|
-
ess/reduce/time_of_flight/
|
|
29
|
-
ess/reduce/time_of_flight/workflow.py,sha256=BAIMeA1bSJlS6JSG7r2srVdtBsAK6VD0DuOiYZuQvNg,3182
|
|
28
|
+
ess/reduce/time_of_flight/types.py,sha256=EroKBxi4WUErNx8d200jh8kqkhwtjAGKIV7PvBUwAJs,1310
|
|
29
|
+
ess/reduce/time_of_flight/workflow.py,sha256=mkgESvQ5Yt3CyAsa1iewkjBOHUqrHm5rRc1EhOQRewQ,2213
|
|
30
30
|
ess/reduce/widgets/__init__.py,sha256=SoSHBv8Dc3QXV9HUvPhjSYWMwKTGYZLpsWwsShIO97Q,5325
|
|
31
31
|
ess/reduce/widgets/_base.py,sha256=_wN3FOlXgx_u0c-A_3yyoIH-SdUvDENGgquh9S-h5GI,4852
|
|
32
32
|
ess/reduce/widgets/_binedges_widget.py,sha256=ZCQsGjYHnJr9GFUn7NjoZc1CdsnAzm_fMzyF-fTKKVY,2785
|
|
@@ -39,9 +39,9 @@ ess/reduce/widgets/_spinner.py,sha256=2VY4Fhfa7HMXox2O7UbofcdKsYG-AJGrsgGJB85nDX
|
|
|
39
39
|
ess/reduce/widgets/_string_widget.py,sha256=iPAdfANyXHf-nkfhgkyH6gQDklia0LebLTmwi3m-iYQ,1482
|
|
40
40
|
ess/reduce/widgets/_switchable_widget.py,sha256=fjKz99SKLhIF1BLgGVBSKKn3Lu_jYBwDYGeAjbJY3Q8,2390
|
|
41
41
|
ess/reduce/widgets/_vector_widget.py,sha256=aTaBqCFHZQhrIoX6-sSqFWCPePEW8HQt5kUio8jP1t8,1203
|
|
42
|
-
essreduce-25.
|
|
43
|
-
essreduce-25.
|
|
44
|
-
essreduce-25.
|
|
45
|
-
essreduce-25.
|
|
46
|
-
essreduce-25.
|
|
47
|
-
essreduce-25.
|
|
42
|
+
essreduce-25.7.0.dist-info/licenses/LICENSE,sha256=nVEiume4Qj6jMYfSRjHTM2jtJ4FGu0g-5Sdh7osfEYw,1553
|
|
43
|
+
essreduce-25.7.0.dist-info/METADATA,sha256=3qNSqqDpvL6iuZoOlcFiI1P6Vyhse0kfAn7K3oeEhxw,3768
|
|
44
|
+
essreduce-25.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
45
|
+
essreduce-25.7.0.dist-info/entry_points.txt,sha256=PMZOIYzCifHMTe4pK3HbhxUwxjFaZizYlLD0td4Isb0,66
|
|
46
|
+
essreduce-25.7.0.dist-info/top_level.txt,sha256=0JxTCgMKPLKtp14wb1-RKisQPQWX7i96innZNvHBr-s,4
|
|
47
|
+
essreduce-25.7.0.dist-info/RECORD,,
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
-
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
|
|
3
|
-
from collections.abc import Mapping
|
|
4
|
-
|
|
5
|
-
import scipp as sc
|
|
6
|
-
import scippnexus as snx
|
|
7
|
-
from scippneutron.chopper import DiskChopper
|
|
8
|
-
|
|
9
|
-
from ..nexus.types import DiskChoppers, Position, SampleRun
|
|
10
|
-
from .types import NumberOfSimulatedNeutrons, SimulationResults
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def simulate_beamline(
|
|
14
|
-
*,
|
|
15
|
-
choppers: Mapping[str, DiskChopper],
|
|
16
|
-
source_position: sc.Variable,
|
|
17
|
-
neutrons: int = 1_000_000,
|
|
18
|
-
pulses: int = 1,
|
|
19
|
-
seed: int | None = None,
|
|
20
|
-
facility: str = 'ess',
|
|
21
|
-
) -> SimulationResults:
|
|
22
|
-
"""
|
|
23
|
-
Simulate a pulse of neutrons propagating through a chopper cascade using the
|
|
24
|
-
``tof`` package (https://tof.readthedocs.io).
|
|
25
|
-
|
|
26
|
-
Parameters
|
|
27
|
-
----------
|
|
28
|
-
choppers:
|
|
29
|
-
A dict of DiskChopper objects representing the choppers in the beamline. See
|
|
30
|
-
https://scipp.github.io/scippneutron/user-guide/chopper/processing-nexus-choppers.html#Build-DiskChopper
|
|
31
|
-
for more information.
|
|
32
|
-
source_position:
|
|
33
|
-
A scalar variable with ``dtype=vector3`` that defines the source position.
|
|
34
|
-
Must be in the same coordinate system as the choppers' axle positions.
|
|
35
|
-
neutrons:
|
|
36
|
-
Number of neutrons to simulate.
|
|
37
|
-
pulses:
|
|
38
|
-
Number of pulses to simulate.
|
|
39
|
-
seed:
|
|
40
|
-
Seed for the random number generator used in the simulation.
|
|
41
|
-
facility:
|
|
42
|
-
Facility where the experiment is performed.
|
|
43
|
-
"""
|
|
44
|
-
import tof
|
|
45
|
-
|
|
46
|
-
tof_choppers = [
|
|
47
|
-
tof.Chopper(
|
|
48
|
-
frequency=abs(ch.frequency),
|
|
49
|
-
direction=tof.AntiClockwise
|
|
50
|
-
if (ch.frequency.value > 0.0)
|
|
51
|
-
else tof.Clockwise,
|
|
52
|
-
open=ch.slit_begin,
|
|
53
|
-
close=ch.slit_end,
|
|
54
|
-
phase=abs(ch.phase),
|
|
55
|
-
distance=sc.norm(
|
|
56
|
-
ch.axle_position - source_position.to(unit=ch.axle_position.unit)
|
|
57
|
-
),
|
|
58
|
-
name=name,
|
|
59
|
-
)
|
|
60
|
-
for name, ch in choppers.items()
|
|
61
|
-
]
|
|
62
|
-
source = tof.Source(facility=facility, neutrons=neutrons, pulses=pulses, seed=seed)
|
|
63
|
-
if not tof_choppers:
|
|
64
|
-
events = source.data.squeeze().flatten(to='event')
|
|
65
|
-
return SimulationResults(
|
|
66
|
-
time_of_arrival=events.coords["birth_time"],
|
|
67
|
-
speed=events.coords["speed"],
|
|
68
|
-
wavelength=events.coords["wavelength"],
|
|
69
|
-
weight=events.data,
|
|
70
|
-
distance=0.0 * sc.units.m,
|
|
71
|
-
)
|
|
72
|
-
model = tof.Model(source=source, choppers=tof_choppers)
|
|
73
|
-
results = model.run()
|
|
74
|
-
# Find name of the furthest chopper in tof_choppers
|
|
75
|
-
furthest_chopper = max(tof_choppers, key=lambda c: c.distance)
|
|
76
|
-
events = results[furthest_chopper.name].data.squeeze().flatten(to='event')
|
|
77
|
-
events = events[
|
|
78
|
-
~(events.masks["blocked_by_others"] | events.masks["blocked_by_me"])
|
|
79
|
-
]
|
|
80
|
-
return SimulationResults(
|
|
81
|
-
time_of_arrival=events.coords["toa"],
|
|
82
|
-
speed=events.coords["speed"],
|
|
83
|
-
wavelength=events.coords["wavelength"],
|
|
84
|
-
weight=events.data,
|
|
85
|
-
distance=furthest_chopper.distance,
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def simulate_chopper_cascade_using_tof(
|
|
90
|
-
choppers: DiskChoppers[SampleRun],
|
|
91
|
-
neutrons: NumberOfSimulatedNeutrons,
|
|
92
|
-
source_position: Position[snx.NXsource, SampleRun],
|
|
93
|
-
) -> SimulationResults:
|
|
94
|
-
"""
|
|
95
|
-
Simulate neutrons traveling through the chopper cascade using the ``tof`` package.
|
|
96
|
-
|
|
97
|
-
Parameters
|
|
98
|
-
----------
|
|
99
|
-
choppers:
|
|
100
|
-
Chopper settings.
|
|
101
|
-
neutrons:
|
|
102
|
-
Number of neutrons to simulate.
|
|
103
|
-
source_position:
|
|
104
|
-
Position of the source.
|
|
105
|
-
"""
|
|
106
|
-
return simulate_beamline(
|
|
107
|
-
choppers=choppers, neutrons=neutrons, source_position=source_position
|
|
108
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|