-
Notifications
You must be signed in to change notification settings - Fork 1
Add monitor normalization #252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
02682c7
fcb95f9
341ccd8
214f645
ff6f184
f229294
b4987e0
eeafdcb
7cba862
2397d65
53b861f
99fa7f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ | |
| live | ||
| logging | ||
| nexus | ||
| normalization | ||
| streaming | ||
| time_of_flight | ||
| ui | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,200 @@ | ||||||
| # SPDX-License-Identifier: BSD-3-Clause | ||||||
| # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) | ||||||
| """Normalization routines for neutron data reduction.""" | ||||||
|
|
||||||
| import functools | ||||||
|
|
||||||
| import scipp as sc | ||||||
|
|
||||||
| from .uncertainty import UncertaintyBroadcastMode, broadcast_uncertainties | ||||||
|
|
||||||
|
|
||||||
| def normalize_by_monitor_histogram( | ||||||
| detector: sc.DataArray, | ||||||
| *, | ||||||
| monitor: sc.DataArray, | ||||||
| uncertainty_broadcast_mode: UncertaintyBroadcastMode, | ||||||
| ) -> sc.DataArray: | ||||||
| """Normalize detector data by a normalized histogrammed monitor. | ||||||
|
|
||||||
| This normalization accounts for both the (wavelength) profile of the incident beam | ||||||
| and the integrated neutron flux, meaning measurement duration and source strength. | ||||||
|
|
||||||
| - For *event* detectors, the monitor values are mapped to the detector | ||||||
| using :func:`scipp.lookup`. That is, for detector event :math:`d_i`, | ||||||
| :math:`m_i` is the monitor bin value at the same coordinate. | ||||||
| - For *histogram* detectors, the monitor is rebinned using to the detector | ||||||
| binning using :func:`scipp.rebin`. Thus, detector value :math:`d_i` and | ||||||
| monitor value :math:`m_i` correspond to the same bin. | ||||||
|
|
||||||
| In both cases, let :math:`x_i` be the lower bound of monitor bin :math:`i` | ||||||
| and let :math:`\\Delta x_i = x_{i+1} - x_i` be the width of that bin. | ||||||
|
|
||||||
| The detector is normalized according to | ||||||
|
|
||||||
| .. math:: | ||||||
|
|
||||||
| d_i^\\text{Norm} = \\frac{d_i}{m_i} \\Delta x_i | ||||||
|
|
||||||
| Parameters | ||||||
| ---------- | ||||||
| detector: | ||||||
| Input detector data. | ||||||
| Must have a coordinate named ``monitor.dim``, that is, the single | ||||||
| dimension name of the **monitor**. | ||||||
| monitor: | ||||||
| A histogrammed monitor. | ||||||
| Must be one-dimensional and have a dimension coordinate, typically "wavelength". | ||||||
| uncertainty_broadcast_mode: | ||||||
| Choose how uncertainties of the monitor are broadcast to the sample data. | ||||||
|
|
||||||
| Returns | ||||||
| ------- | ||||||
| : | ||||||
| ``detector`` normalized by ``monitor``. | ||||||
| If the monitor has masks or contains non-finite values, the output has a mask | ||||||
| called '_monitor_mask' constructed from the monitor masks and non-finite values. | ||||||
|
|
||||||
| See also | ||||||
| -------- | ||||||
| normalize_by_monitor_integrated: | ||||||
| Normalize by an integrated monitor. | ||||||
| """ | ||||||
| _check_monitor_range_contains_detector(monitor=monitor, detector=detector) | ||||||
|
|
||||||
| dim = monitor.dim | ||||||
|
|
||||||
| if detector.bins is None: | ||||||
| monitor = monitor.rebin({dim: detector.coords[dim]}) | ||||||
| detector = _mask_detector_for_norm(detector=detector, monitor=monitor) | ||||||
| coord = monitor.coords[dim] | ||||||
| delta_w = sc.DataArray(coord[1:] - coord[:-1], masks=monitor.masks) | ||||||
| norm = broadcast_uncertainties( | ||||||
| monitor / delta_w, prototype=detector, mode=uncertainty_broadcast_mode | ||||||
| ) | ||||||
|
|
||||||
| if detector.bins is None: | ||||||
| return detector / norm.rebin({dim: detector.coords[dim]}) | ||||||
| return detector.bins / sc.lookup(norm, dim=dim) | ||||||
|
|
||||||
|
|
||||||
| def normalize_by_monitor_integrated( | ||||||
| detector: sc.DataArray, | ||||||
| *, | ||||||
| monitor: sc.DataArray, | ||||||
| uncertainty_broadcast_mode: UncertaintyBroadcastMode, | ||||||
| ) -> sc.DataArray: | ||||||
| """Normalize detector data by an integrated monitor. | ||||||
|
|
||||||
| This normalization accounts only for the integrated neutron flux, | ||||||
| meaning measurement duration and source strength. | ||||||
| It does *not* account for the (wavelength) profile of the incident beam. | ||||||
| For that, see :func:`normalize_by_monitor_histogram`. | ||||||
|
|
||||||
| Let :math:`d_i` be a detector event or the counts in a detector bin. | ||||||
| The normalized detector is | ||||||
|
|
||||||
| .. math:: | ||||||
|
|
||||||
| d_i^\\text{Norm} = \\frac{d_i}{\\sum_j\\, m_j} | ||||||
|
|
||||||
| where :math:`m_j` is the monitor counts in bin :math:`j`. | ||||||
| Note that this is not a true integral but only a sum over monitor events. | ||||||
|
|
||||||
| The result depends on the range of the monitor but not its | ||||||
| binning within that range. | ||||||
|
|
||||||
| Parameters | ||||||
| ---------- | ||||||
| detector: | ||||||
| Input detector data. | ||||||
| monitor: | ||||||
| A histogrammed monitor. | ||||||
| Must be one-dimensional and have a dimension coordinate, typically "wavelength". | ||||||
| uncertainty_broadcast_mode: | ||||||
| Choose how uncertainties of the monitor are broadcast to the sample data. | ||||||
|
|
||||||
| Returns | ||||||
| ------- | ||||||
| : | ||||||
| `detector` normalized by a monitor. | ||||||
| If the monitor has masks or contains non-finite values, the output has a mask | ||||||
| called '_monitor_mask' constructed from the monitor masks and non-finite values. | ||||||
|
|
||||||
| See also | ||||||
| -------- | ||||||
| normalize_by_monitor_histogram: | ||||||
| Normalize by a monitor histogram. | ||||||
| """ | ||||||
| _check_monitor_range_contains_detector(monitor=monitor, detector=detector) | ||||||
| detector = _mask_detector_for_norm(detector=detector, monitor=monitor) | ||||||
| norm = monitor.data.sum() | ||||||
| norm = broadcast_uncertainties( | ||||||
| norm, prototype=detector, mode=uncertainty_broadcast_mode | ||||||
| ) | ||||||
| return detector / norm | ||||||
jl-wynen marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
|
||||||
|
|
||||||
| def _check_monitor_range_contains_detector( | ||||||
| *, monitor: sc.DataArray, detector: sc.DataArray | ||||||
| ) -> None: | ||||||
| dim = monitor.dim | ||||||
| if not monitor.coords.is_edges(dim): | ||||||
| raise sc.CoordError( | ||||||
| f"Monitor coordinate '{dim}' must be bin-edges to integrate the monitor." | ||||||
| ) | ||||||
|
|
||||||
| # Prefer a bin coord over an event coord because this makes the behavior for binned | ||||||
| # and histogrammed data consistent. If we used an event coord, we might allow a | ||||||
| # monitor range that is less than the detector bins which is fine for the events, | ||||||
| # but would be wrong if the detector was subsequently histogrammed. | ||||||
| if (det_coord := detector.coords.get(dim)) is not None: | ||||||
| lo = det_coord[dim, :-1].nanmin() | ||||||
| hi = det_coord[dim, 1:].nanmax() | ||||||
| elif (det_coord := detector.bins.coords.get(dim)) is not None: | ||||||
| lo = det_coord.nanmin() | ||||||
| hi = det_coord.nanmax() | ||||||
| else: | ||||||
| raise sc.CoordError( | ||||||
| f"Missing '{dim}' coordinate in detector for monitor normalization." | ||||||
| ) | ||||||
|
|
||||||
| if monitor.coords[dim].min() > lo or monitor.coords[dim].max() < hi: | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. That is what this condition checks.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My point is, shouldn't it raise if
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you asking whether the error condition should be This would not be correct for histogrammed detectors: essreduce/tests/correction_test.py Line 61 in 52763fc
For binned detectors, it is also fine as long as there is a bin-coord: essreduce/tests/correction_test.py Line 15 in 52763fc
If there is no bin-coord, then it gets tricky. I am not sure the code does the right thing in this case. E.g., consider def test_normalize_by_monitor_histogram_aligned_bins_wo_bin_coord() -> None:
detector = (
sc.DataArray(
sc.array(dims=['w'], values=[0, 10, 30], unit='counts'),
coords={'w': sc.arange('w', 3.0, unit='Å')},
)
.bin(w=sc.array(dims=['w'], values=[0.0, 2, 3], unit='Å'))
.drop_coords('w')
)
monitor = sc.DataArray(
sc.array(dims=['w'], values=[5.0, 6.0], unit='counts'),
coords={'w': sc.array(dims=['w'], values=[0.0, 2, 3], unit='Å')},
)
normalized = normalize_by_monitor_histogram(
detector,
monitor=monitor,
uncertainty_broadcast_mode=UncertaintyBroadcastMode.fail,
)
expected = (
sc.DataArray(
sc.array(dims=['w'], values=[0.0, 44 / 3, 55 / 3], unit='counts'),
coords={'w': sc.arange('w', 3.0, unit='Å')},
)
.bin(w=sc.array(dims=['w'], values=[0.0, 2, 3], unit='Å'))
.drop_coords('w')
)
sc.testing.assert_identical(normalized, expected)I would expect this to work based on the event coords. But |
||||||
| raise ValueError( | ||||||
| f"Cannot normalize by monitor: The {dim} range of the monitor " | ||||||
| f"({monitor.coords[dim].min():c} to {monitor.coords[dim].max():c}) " | ||||||
| f"is smaller than the range of the detector ({lo:c} to {hi:c})." | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| def _mask_detector_for_norm( | ||||||
| *, detector: sc.DataArray, monitor: sc.DataArray | ||||||
| ) -> sc.DataArray: | ||||||
| """Mask the detector where the monitor is masked. | ||||||
|
|
||||||
| For performance, this applies the monitor mask to the detector bins. | ||||||
| This can lead to masking more events than strictly necessary if we | ||||||
| used an event mask. | ||||||
| """ | ||||||
| if (monitor_mask := _monitor_mask(monitor)) is None: | ||||||
| return detector | ||||||
|
|
||||||
| # Use rebin to reshape the mask to the detector: | ||||||
| dim = monitor.dim | ||||||
| mask = sc.DataArray(monitor_mask, coords={dim: monitor.coords[dim]}).rebin( | ||||||
| {dim: detector.coords[dim]} | ||||||
| ).data != sc.scalar(0, unit=None) | ||||||
|
Comment on lines
+184
to
+186
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Won't this go badly wrong (masking everything) if we have an event-mode detector with just 1 or few bins along
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. But why would we? I think a more realistic problem is that we don't have a wavelength bin coord because we never binned in wavelength. I will update for that. If you want to support arrays like you describe, then we pretty much have to ignore the existing binning and always operate on the events. Meaning we need to create an event mask and use the events in the range calculations above.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, if we require a "reasonable" wavelength dim length for the detector then we need to make that very clear. I think it also implies that data must NOT be in detector-space any more, as otherwise we get too many bins?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, was this addressed somehow?
Comment on lines
+171
to
+186
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I missed this part when I reviewed earlier.
|
||||||
| return detector.assign_masks({"_monitor_mask": mask}) | ||||||
|
|
||||||
|
|
||||||
| def _monitor_mask(monitor: sc.DataArray) -> sc.Variable | None: | ||||||
| """Mask nonfinite monitor values and combine all masks.""" | ||||||
| masks = list(monitor.masks.values()) | ||||||
|
|
||||||
| finite = sc.isfinite(monitor.data) | ||||||
| if not finite.all(): | ||||||
| masks.append(~finite) | ||||||
|
|
||||||
| if not masks: | ||||||
| return None | ||||||
| return functools.reduce(sc.logical_or, masks) | ||||||
Uh oh!
There was an error while loading. Please reload this page.