Skip to content

Commit 52763fc

Browse files
committed
Add monitor normalization
1 parent 6f720f6 commit 52763fc

File tree

2 files changed

+669
-0
lines changed

2 files changed

+669
-0
lines changed

src/ess/reduce/correction.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3+
"""Correction algorithms for neutron data reduction."""
4+
5+
import scipp as sc
6+
7+
from .uncertainty import UncertaintyBroadcastMode, broadcast_uncertainties
8+
9+
10+
def normalize_by_monitor_histogram(
11+
detector: sc.DataArray,
12+
*,
13+
monitor: sc.DataArray,
14+
uncertainty_broadcast_mode: UncertaintyBroadcastMode,
15+
) -> sc.DataArray:
16+
"""Normalize detector data by a histogrammed monitor.
17+
18+
First, the monitor is clipped to the range of the detector
19+
20+
.. math::
21+
22+
\\bar{m}_i = m_i I(x_i, x_{i+1}),
23+
24+
where :math:`m_i` is the monitor intensity in bin :math:`i`,
25+
:math:`x_i` is the lower bin edge of bin :math:`i`, and
26+
:math:`I(x_i, x_{i+1})` selects bins that are within the range of the detector.
27+
28+
The detector bins :math:`d_i` are normalized according to
29+
30+
.. math::
31+
32+
d_i^\\text{Norm} = \\frac{d_i}{\\bar{m}_i} \\Delta x_i
33+
\\frac{\\sum_j\\,\\bar{m}_j}{\\sum_j\\,\\Delta x_j}
34+
35+
where :math:`\\Delta x_i` is the width of monitor bin :math:`i` (see below).
36+
This normalization leads to a result that has the same
37+
unit as the input detector data.
38+
39+
Monitor bin :math:`i` is chosen according to:
40+
41+
- *Histogrammed detector*: The monitor is
42+
`rebinned <https://scipp.github.io/generated/functions/scipp.rebin.html>`_
43+
to the detector binning. This distributes the monitor weights to the
44+
detector bins.
45+
- *Binned detector*: The monitor value for bin :math:`i` is determined via
46+
:func:`scipp.lookup`. This means that for each event, the monitor value
47+
is obtained from the monitor histogram at that event coordinate value.
48+
49+
This function is based on the implementation in
50+
`NormaliseToMonitor <https://docs.mantidproject.org/nightly/algorithms/NormaliseToMonitor-v1.html>`_
51+
of Mantid.
52+
53+
Parameters
54+
----------
55+
detector:
56+
Input detector data.
57+
Must have a coordinate named ``monitor.dim``, that is, the single
58+
dimension name of the monitor.
59+
monitor:
60+
A histogrammed monitor.
61+
Must be one-dimensional and have a dimension coordinate, typically "wavelength".
62+
uncertainty_broadcast_mode:
63+
Choose how uncertainties of the monitor are broadcast to the sample data.
64+
65+
Returns
66+
-------
67+
:
68+
``detector`` normalized by ``monitor``.
69+
70+
See also
71+
--------
72+
normalize_by_monitor_integrated:
73+
Normalize by an integrated monitor.
74+
"""
75+
dim = monitor.dim
76+
77+
clipped = _clip_monitor_to_detector_range(monitor=monitor, detector=detector)
78+
coord = clipped.coords[dim]
79+
delta_w = coord[1:] - coord[:-1]
80+
total_monitor_weight = broadcast_uncertainties(
81+
clipped.sum() / delta_w.sum(),
82+
prototype=clipped,
83+
mode=uncertainty_broadcast_mode,
84+
)
85+
delta_w *= total_monitor_weight
86+
norm = broadcast_uncertainties(
87+
clipped / delta_w, prototype=detector, mode=uncertainty_broadcast_mode
88+
)
89+
90+
if detector.bins is None:
91+
return detector / norm
92+
return detector.bins / sc.lookup(norm, dim=dim)
93+
94+
95+
def normalize_by_monitor_integrated(
96+
detector: sc.DataArray,
97+
*,
98+
monitor: sc.DataArray,
99+
uncertainty_broadcast_mode: UncertaintyBroadcastMode,
100+
) -> sc.DataArray:
101+
"""Normalize detector data by an integrated monitor.
102+
103+
The monitor is integrated according to
104+
105+
.. math::
106+
107+
M = \\sum_{i=0}^{N-1}\\, m_i (x_{i+1} - x_i) I(x_i, x_{i+1}),
108+
109+
where :math:`m_i` is the monitor intensity in bin :math:`i`,
110+
:math:`x_i` is the lower bin edge of bin :math:`i`, and
111+
:math:`I(x_i, x_{i+1})` selects bins that are within the range of the detector.
112+
113+
Parameters
114+
----------
115+
detector:
116+
Input detector data.
117+
monitor:
118+
A histogrammed monitor.
119+
Must be one-dimensional and have a dimension coordinate, typically "wavelength".
120+
uncertainty_broadcast_mode:
121+
Choose how uncertainties of the monitor are broadcast to the sample data.
122+
123+
Returns
124+
-------
125+
:
126+
`detector` normalized by a monitor.
127+
128+
See also
129+
--------
130+
normalize_by_monitor_histogram:
131+
Normalize by a monitor histogram without integration.
132+
"""
133+
clipped = _clip_monitor_to_detector_range(monitor=monitor, detector=detector)
134+
coord = clipped.coords[clipped.dim]
135+
norm = (clipped * (coord[1:] - coord[:-1])).data.sum()
136+
norm = broadcast_uncertainties(
137+
norm, prototype=detector, mode=uncertainty_broadcast_mode
138+
)
139+
return detector / norm
140+
141+
142+
def _clip_monitor_to_detector_range(
143+
*, monitor: sc.DataArray, detector: sc.DataArray
144+
) -> sc.DataArray:
145+
dim = monitor.dim
146+
if not monitor.coords.is_edges(dim):
147+
raise sc.CoordError(
148+
f"Monitor coordinate '{dim}' must be bin-edges to integrate the monitor."
149+
)
150+
151+
# Prefer a bin coord over an event coord because this makes the behavior for binned
152+
# and histogrammed data consistent. If we used an event coord, we might allow a
153+
# monitor range that is less than the detector bins which is fine for the vents,
154+
# but would be wrong if the detector was subsequently histogrammed.
155+
if dim in detector.coords:
156+
det_coord = detector.coords[dim]
157+
158+
# Mask zero-count bins, which are an artifact from the rectangular 2-D binning.
159+
# The wavelength of those bins must be excluded when determining the range.
160+
if detector.bins is None:
161+
mask = detector.data == sc.scalar(0.0, unit=detector.unit)
162+
else:
163+
mask = detector.data.bins.size() == sc.scalar(0.0, unit=None)
164+
lo = (
165+
sc.DataArray(det_coord[dim, :-1], masks={'zero_counts': mask}).nanmin().data
166+
)
167+
hi = sc.DataArray(det_coord[dim, 1:], masks={'zero_counts': mask}).nanmax().data
168+
169+
elif dim in detector.bins.coords:
170+
det_coord = detector.bins.coords[dim]
171+
# No need to mask here because we have the exact event coordinate values.
172+
lo = det_coord.nanmin()
173+
hi = det_coord.nanmax()
174+
175+
else:
176+
raise sc.CoordError(
177+
f"Missing '{dim}' coordinate in detector for monitor normalization."
178+
)
179+
180+
if monitor.coords[dim].min() > lo or monitor.coords[dim].max() < hi:
181+
raise ValueError(
182+
f"Cannot normalize by monitor: The {dim} range of the monitor "
183+
f"({monitor.coords[dim].min().value} to {monitor.coords[dim].max().value}) "
184+
f"is smaller than the range of the detector ({lo.value} to {hi.value})."
185+
)
186+
187+
if detector.bins is None:
188+
# If we didn't rebin to the detector coord here, then, for a finer monitor
189+
# binning than detector, the lookup table would extract one monitor value for
190+
# each detector bin and ignore other values lying in the same detector bin.
191+
# But integration would pick up all monitor bins.
192+
return monitor.rebin({dim: det_coord})
193+
return monitor[dim, lo:hi]

0 commit comments

Comments
 (0)