Skip to content

Commit 7f4ca04

Browse files
authored
fix: reduction workflow for 3D BEER detectors (#254)
* fix: make sure coord remains after binnning * test: skip if cif-db not reachable * fix: make sure coord remains after binnning * test: skip if cif-db not reachable * feat: round fitted t0 to nearest chopper opening * remove comment * refactor: better name for frame cutoff time coordinate * test: add test reducing 3D dataset using automatic peak finder workflow * refactor: better name for chopper delay * docs: explain rounding step * docs: motivation for clamping origin times * docs: reformulate
1 parent 3ccdb26 commit 7f4ca04

6 files changed

Lines changed: 85 additions & 21 deletions

File tree

src/ess/beer/clustering.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def cluster_events_by_streak(
2020

2121
# We need to keep these coordinates after binning,
2222
# adding them to the binned data coords achieves this.
23-
for coord in ('two_theta', 'Ltotal'):
23+
for coord in ('two_theta', 'Ltotal', 'frame_cutoff_time'):
2424
da.bins.coords[coord] = sc.bins_like(da, da.coords[coord])
2525

2626
h = da.bins.concat().hist(coarse_d=1000)

src/ess/beer/conversions.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
def compute_tof_in_each_cluster(
2020
da: StreakClusteredData[RunType],
21+
chopper_delay: WavelengthDefinitionChopperDelay,
2122
mod_period: ModulationPeriod,
2223
) -> TofDetector[RunType]:
2324
"""Fits a line through each cluster, the intercept of the line is t0.
@@ -32,6 +33,7 @@ def compute_tof_in_each_cluster(
3233
of the points in the cluster, and probably should belong to another cluster or
3334
are part of the background.
3435
3. Go back to 1) and iterate until convergence. A few iterations should be enough.
36+
4. Finally, round the estimated t0 to the closest known chopper opening time.
3537
"""
3638
if isinstance(da, sc.DataGroup):
3739
return sc.DataGroup(
@@ -42,7 +44,7 @@ def compute_tof_in_each_cluster(
4244
sin_theta_L = sc.sin(da.bins.coords['two_theta'] / 2) * da.bins.coords['Ltotal']
4345
t = time_of_arrival(
4446
da.bins.coords['event_time_offset'],
45-
da.coords['tc'].to(unit=da.bins.coords['event_time_offset'].unit),
47+
da.bins.coords['frame_cutoff_time'],
4648
)
4749
for _ in range(15):
4850
s, t0 = _linear_regression_by_bin(sin_theta_L, t, da.data)
@@ -54,11 +56,33 @@ def compute_tof_in_each_cluster(
5456
too_far_from_center=(distance_to_self > max_distance_from_streak_line),
5557
)
5658

57-
da = da.assign_coords(t0=sc.values(t0))
58-
da = da.bins.assign_coords(tof=(t - sc.values(t0)))
59+
# The t0 estimate from fitting is influenced by peak overlap, background,
60+
# and other factors that can make the estimate offset from the true
61+
# chopper opening time that it should match.
62+
# We know the true chopper opening times, so instead of using the t0 estimte
63+
# directly we can round the estimate to the closest chopper opening time.
64+
# That way the t0 estimate becomes more robust and is guaranteed to correspond to
65+
# a true chopper opening time.
66+
t0 = _round_t0_to_nearest_chopper_opening(sc.values(t0), mod_period, chopper_delay)
67+
da = da.assign_coords(t0=t0)
68+
da = da.bins.assign_coords(tof=(t - t0))
5969
return da
6070

6171

72+
def _round_t0_to_nearest_chopper_opening(
73+
t0: sc.Variable,
74+
mod_period: sc.Variable,
75+
chopper_delay: sc.Variable,
76+
) -> sc.Variable:
77+
out = t0 - chopper_delay
78+
out /= mod_period
79+
out += 0.5
80+
sc.floor(out, out=out)
81+
out *= mod_period
82+
out += chopper_delay
83+
return out
84+
85+
6286
def _linear_regression_by_bin(
6387
x: sc.Variable, y: sc.Variable, w: sc.Variable
6488
) -> tuple[sc.Variable, sc.Variable]:
@@ -118,14 +142,14 @@ def _compute_d_given_list_of_peaks(
118142

119143
def time_of_arrival(
120144
event_time_offset: sc.Variable,
121-
tc: sc.Variable,
145+
frame_cutoff_time: sc.Variable,
122146
):
123147
"""Does frame unwrapping for pulse shaping chopper modes.
124148
125-
Events before the "cutoff time" `tc` are assumed to come from the previous pulse."""
149+
Events before the "cutoff time" are assumed to come from the previous pulse."""
126150
_eto = event_time_offset
127151
T = sc.scalar(1 / 14, unit='s').to(unit=_eto.unit)
128-
tc = tc.to(unit=_eto.unit)
152+
tc = frame_cutoff_time.to(unit=_eto.unit)
129153
return sc.where(_eto >= tc, _eto, _eto + T)
130154

131155

@@ -135,13 +159,13 @@ def _tof_from_dhkl(
135159
coarse_dhkl: sc.Variable,
136160
Ltotal: sc.Variable,
137161
mod_period: sc.Variable,
138-
time0: sc.Variable,
162+
chopper_delay: sc.Variable,
139163
) -> sc.Variable:
140164
"""Computes tof for BEER given the dhkl peak that the event belongs to"""
141165
# Source: https://www.mcstas.org/download/components/current/contrib/NPI_tof_dhkl_detector.comp
142166
# tref = 2 * d_hkl * sin(theta) / hm * Ltotal
143-
# tc = time_of_arrival - time0 - tref
144-
# dt = floor(tc / mod_period + 0.5) * mod_period + time0
167+
# tc = time_of_arrival - chopper_delay - tref
168+
# dt = floor(tc / mod_period + 0.5) * mod_period + chopper_delay
145169
# tof = time_of_arrival - dt
146170
c = (-2 * 1.0 / (scipp.constants.h / scipp.constants.m_n)).to(
147171
unit=f'{time_of_arrival.unit}/m/angstrom'
@@ -150,12 +174,12 @@ def _tof_from_dhkl(
150174
out *= sc.sin(theta)
151175
out *= Ltotal
152176
out += time_of_arrival
153-
out -= time0
177+
out -= chopper_delay
154178
out /= mod_period
155179
out += 0.5
156180
sc.floor(out, out=out)
157181
out *= mod_period
158-
out += time0
182+
out += chopper_delay
159183
out *= -1
160184
out += time_of_arrival
161185
return out
@@ -168,7 +192,7 @@ def geometry_graph() -> GeometryCoordTransformGraph:
168192
def tof_from_known_dhkl_graph(
169193
mod_period: ModulationPeriod,
170194
pulse_length: PulseLength,
171-
time0: WavelengthDefinitionChopperDelay,
195+
chopper_delay: WavelengthDefinitionChopperDelay,
172196
dhkl_list: DHKLList,
173197
gg: GeometryCoordTransformGraph,
174198
) -> TofCoordTransformGraph:
@@ -200,7 +224,7 @@ def _compute_coarse_dspacing(
200224
**graph.tof.elastic("tof"),
201225
'pulse_length': lambda: pulse_length,
202226
'mod_period': lambda: mod_period,
203-
'time0': lambda: time0,
227+
'chopper_delay': lambda: chopper_delay,
204228
'tof': _tof_from_dhkl,
205229
'time_of_arrival': time_of_arrival,
206230
'coarse_dhkl': _compute_coarse_dspacing,

src/ess/beer/data.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
# Simulation with 3D detector model - almost no events
4545
# - only used to verify we can load the 3D geometry.
4646
"few_neutrons_3d_detector_example.h5": "md5:88cbe29cb539c8acebf9fd7cee9d3c57",
47+
"silicon-mode9-3d-more-neutrons.h5": "md5:a6afdc4ee9827a57f88c2a3c6ef27383",
4748
},
4849
)
4950

@@ -81,6 +82,10 @@ def mcstas_few_neutrons_3d_detector_example():
8182
return _registry.get_path('few_neutrons_3d_detector_example.h5')
8283

8384

85+
def mcstas_more_neutrons_3d_detector_example():
86+
return _registry.get_path('silicon-mode9-3d-more-neutrons.h5')
87+
88+
8489
def duplex_peaks() -> Path:
8590
return _registry.get_path('duplex-dhkl.tab')
8691

src/ess/beer/io.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,8 @@ def _load_beer_mcstas(f, north_or_south=None, *, number):
234234
# Bin detector panel into rectangular "pixels"
235235
# similar in size to the physical detector pixels.
236236
da = da.bin(
237-
y=sc.linspace('y', -0.5, 0.5, 501, unit='m'),
238-
x=sc.linspace('x', -0.5, 0.5, 201, unit='m'),
237+
y=sc.linspace('y', -0.5, 0.5, 201, unit='m'),
238+
x=sc.linspace('x', -0.5, 0.5, 501, unit='m'),
239239
)
240240

241241
# Compute the position of each pixel in the global coordinate system.
@@ -279,7 +279,7 @@ def _load_beer_mcstas(f, north_or_south=None, *, number):
279279
# Used in modulation mode automatic-peak-finding reduction to estimate d.
280280
# In practice this will probably be replaced by the regular tof workflow.
281281
# But I'm not 100% sure.
282-
da.coords["tc"] = (
282+
da.coords["frame_cutoff_time"] = (
283283
sc.constants.m_n
284284
/ sc.constants.h
285285
* da.coords['wavelength_estimate']

tests/beer/mcstas_reduction_test.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import numpy as np
2+
import pytest
23
import scipp as sc
34
import scippneutron as scn
45
from scipp.testing import assert_allclose
@@ -12,9 +13,14 @@
1213
duplex_peaks_array,
1314
mcstas_duplex,
1415
mcstas_few_neutrons_3d_detector_example,
16+
mcstas_more_neutrons_3d_detector_example,
1517
mcstas_silicon_new_model,
1618
)
17-
from ess.beer.io import load_beer_mcstas, load_beer_mcstas_monitor
19+
from ess.beer.io import (
20+
load_beer_mcstas,
21+
load_beer_mcstas_monitor,
22+
mcstas_chopper_delay_from_mode_new_simulations,
23+
)
1824
from ess.beer.types import DetectorBank, DHKLList, TofDetector
1925
from ess.reduce.nexus.types import Filename, SampleRun
2026

@@ -40,10 +46,14 @@ def test_can_reduce_using_known_peaks_workflow():
4046
)
4147

4248

43-
def test_can_reduce_using_unknown_peaks_workflow():
49+
@pytest.mark.parametrize(
50+
'fname', [mcstas_silicon_new_model(7), mcstas_more_neutrons_3d_detector_example()]
51+
)
52+
def test_can_reduce_using_unknown_peaks_workflow(fname):
4453
wf = BeerModMcStasWorkflow()
45-
wf[Filename[SampleRun]] = mcstas_duplex(7)
54+
wf[Filename[SampleRun]] = fname
4655
wf[DetectorBank] = DetectorBank.north
56+
wf.insert(mcstas_chopper_delay_from_mode_new_simulations)
4757
da = wf.compute(TofDetector[SampleRun])
4858
da = da.transform_coords(
4959
('dspacing',),
@@ -53,7 +63,11 @@ def test_can_reduce_using_unknown_peaks_workflow():
5363
max_peak_d = sc.midpoints(h['dspacing', np.argmax(h.values)].coords['dspacing'])[0]
5464
assert_allclose(
5565
max_peak_d,
56-
sc.scalar(2.0407, unit='angstrom'),
66+
# The two peaks around 1.6 are very similar in magnitude,
67+
# so either of them can be bigger and that is fine.
68+
sc.scalar(1.5677, unit='angstrom')
69+
if max_peak_d < sc.scalar(1.6, unit='angstrom')
70+
else sc.scalar(1.6374, unit='angstrom'),
5771
atol=sc.scalar(1e-2, unit='angstrom'),
5872
)
5973

tests/diffraction/test_peaks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1+
import urllib.request
2+
3+
import pytest
14
import scipp as sc
25

36
from ess.diffraction.peaks import dspacing_peaks_from_cif
47

58

9+
def _cifdb_is_reachable(timeout=5):
10+
try:
11+
req = urllib.request.Request('http://cod.ibt.lt', method="HEAD")
12+
with urllib.request.urlopen(req, timeout=timeout): # noqa: S310
13+
return True
14+
except Exception:
15+
return False
16+
17+
18+
@pytest.mark.skipif(
19+
not _cifdb_is_reachable(), reason='The CIF database can not be reached.'
20+
)
621
def test_retreived_peak_list_has_expected_units():
722
d = dspacing_peaks_from_cif(
823
'codid::9011998',
@@ -12,6 +27,9 @@ def test_retreived_peak_list_has_expected_units():
1227
assert d.unit == 'barn'
1328

1429

30+
@pytest.mark.skipif(
31+
not _cifdb_is_reachable(), reason='The CIF database can not be reached.'
32+
)
1533
def test_intensity_threshold_is_taken_into_account():
1634
d = dspacing_peaks_from_cif(
1735
'codid::9011998',
@@ -26,6 +44,9 @@ def test_intensity_threshold_is_taken_into_account():
2644
assert len(d) == 0
2745

2846

47+
@pytest.mark.skipif(
48+
not _cifdb_is_reachable(), reason='The CIF database can not be reached.'
49+
)
2950
def test_retreived_peak_list_with_temperature_kwarg():
3051
d = dspacing_peaks_from_cif(
3152
'codid::9011998', sc.scalar(50, unit='barn'), uiso_temperature=300

0 commit comments

Comments
 (0)