Skip to content

Conversation

@jl-wynen
Copy link
Member

@jl-wynen jl-wynen commented Jul 7, 2025

This addresses scipp/essdiffraction#167

I moved the code here because it is rather involved and it will be needed by spectroscopy and reflectometry as well. Once this is merged, I will prepare a PR in ESSdiffraction to use these new implementations.

Note that I changed the behaviour. The code now favours bin coordinates over event coordinates when selecting the monitor range. Here is an example why: Let's say we have detector data where the last event is at 1.9Å but the last bin ends at 2Å. And we have a monitor with data up to 1.91Å. If we check ranges based on events, we would be allowed to normalize. But if we histogrammed after normalization, we would get a detector bin that extends to 2Å even though we only have normalization data for part of this bin. The chosen implementation avoids this problem. In practice this probably does not matter because bin widths should be small enough to limit detector bins to be within the monitor range.

Further, for histogrammed detectors, the monitor is now rebinned to match the detector instead of using lookup. This assigns more accurate weights to each bin. And, AFAIK, this matches Mantid.

# each detector bin and ignore other values lying in the same detector bin.
# But integration would pick up all monitor bins.
return monitor.rebin({dim: det_coord})
return monitor[dim, lo:hi]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good but can you briefly outline which code was simply copied over and which code is new? Thanks :-)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing is copied without change. But normalize_by_monitor_integrated is unchanged apart from extracting _clip_monitor_to_detector_range. However, the latter has changed as I described in my initial comment.

normalize_by_monitor_histogram now also clips the data. And the actual normalisation has changed. It used to be https://github.com/scipp/essdiffraction/blob/c9c29dd6155cdb0f9fd201e6c9d6923a754b1e37/src/ess/powder/correction.py#L57-L67, i.e., only det / mon without extra scaling factors.

Comment on lines 158 to 159
# Mask zero-count bins, which are an artifact from the rectangular 2-D binning.
# The wavelength of those bins must be excluded when determining the range.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the assumption that we are not in detector space any more, but something like two_theta, as we did in essdiffraction?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment does. But the code does not. Do you think we shouldn't mask in all cases?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should not. Detector pixels can have true zeros (maybe unlikely with significant background?), but that should not limit the wavelength range?

f"Missing '{dim}' coordinate in detector for monitor normalization."
)

if monitor.coords[dim].min() > lo or monitor.coords[dim].max() < hi:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi is the largest event, don't we need hi < mon.max for lookup to do its job?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. That is what this condition checks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point is, shouldn't it raise if hi is not less then the max? Currently it checks of max is less than hi, which is not the same, or is it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you asking whether the error condition should be monitor.coords[dim].max() <= hi?

This would not be correct for histogrammed detectors:

def test_normalize_by_monitor_histogram_aligned_bins_hist() -> None:

For binned detectors, it is also fine as long as there is a bin-coord:

def test_normalize_by_monitor_histogram_aligned_bins_w_event_coord() -> None:

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 hi is computed to be in this case which removes the upper monitor bin. This is one of the issues I was trying to avoid with this implementation but apparently failed. I am not sure how to best handle this case.

Comment on lines 181 to 167
raise ValueError(
f"Cannot normalize by monitor: The {dim} range of the monitor "
f"({monitor.coords[dim].min().value} to {monitor.coords[dim].max().value}) "
f"is smaller than the range of the detector ({lo.value} to {hi.value})."
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we deal with cases where the range is different, like I encounter in essdiffraction/beamlime? Above it seems not even a wavelength mask is taken into account, that is I this is just a dead end?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can extend this to take masks into account. But in general, you should slice your data to be within the monitor range.

Note that this has not changed compared to the implementation in ESSdiffraction.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really? The only way I could make essdiffraction work for me was by masking a wavelength range, or else I would get exceptions in the monitor normalization.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But in general, you should slice your data to be within the monitor range.

You who? Where do the workflows do this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a separate step. It should be clearly visible that we automatically limit the wavelength range of the data.

"""
clipped = _clip_monitor_to_detector_range(monitor=monitor, detector=detector)
coord = clipped.coords[clipped.dim]
norm = (clipped * (coord[1:] - coord[:-1])).data.sum()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to ask:

  • Why ignore masks?
  • Do we need nansum?
    But then I wondered if both of that is anyway invalid in the integrated case (unless the detector is masked in the same range)? So maybe the real question should be:

If there are masks (or nans) and we integrate, we should:

  • Apply the mask to the detector.
  • Mask the nan ranges (of the monitor) in the detector
  • Do not ignore the the mask in the integration.
  • Use nansum.

Or am I missing something?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are right. We need the union of the detector and monitor masks.

coord = clipped.coords[dim]
delta_w = coord[1:] - coord[:-1]
total_monitor_weight = broadcast_uncertainties(
clipped.sum() / delta_w.sum(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nansum, maybe?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where would NaN values come from? And if there are any, should we instead mask them? See discussion above.

@SimonHeybrock
Copy link
Member

@jl-wynen Should we get this ready?

@jl-wynen jl-wynen force-pushed the monitor-normalization branch from 52763fc to 93eff4b Compare October 20, 2025 13:29
@jl-wynen
Copy link
Member Author

jl-wynen commented Oct 20, 2025

The implementation for monitor masks is incomplete. I need to figure out how to handle mismatched shapes...

@jl-wynen jl-wynen force-pushed the monitor-normalization branch from 93eff4b to 8d45af3 Compare October 24, 2025 07:28
@jl-wynen
Copy link
Member Author

Ready now. @SimonHeybrock can you take another look?

@SimonHeybrock
Copy link
Member

For now, here is Claude's assessment. Will have a look myself now!


Review of PR #252: Add monitor normalization

I've reviewed the PR with particular focus on the recent changes (commits 489c65e "Respect masks in range calculation" and 8d45af3 "Apply monitor masks"). Here's my detailed analysis:

Overall Assessment

The PR adds well-designed monitor normalization functionality with two approaches:

  1. Histogram normalization - bin-by-bin normalization preserving spectral information
  2. Integrated normalization - simple scalar normalization factor

The implementation is mathematically sound and well-documented. The recent changes improve mask handling significantly.

Recent Changes Analysis

Commit 489c65e: "Respect masks in range calculation"

Strengths:

  • Replaced special-case zero-count handling with proper mask respect - much cleaner approach
  • Simplified code using walrus operators (:=)
  • Correctly uses mask.all() to reduce along non-monitor dimensions, ensuring bins with ANY unmasked data are included
  • Improved error messages using :c format specifier

Design rationale (lines 172-176):
The use of mask.all() is correct: it reduces masks along dimensions perpendicular to the monitor dimension, marking a bin as masked only if ALL its elements are masked. This ensures bins with any valid data contribute to range calculations.

Commit 8d45af3: "Apply monitor masks"

Strengths:

  • Adds proper monitor mask handling via _mask_detector_for_norm() and _monitor_mask()
  • Automatically masks non-finite monitor values (NaN, Inf)
  • Excellent test coverage (aligned/unaligned bins, multiple masks, edge cases, non-finite values)
  • Clear documentation warnings about mask impact on normalization factors

Implementation details:

The mask reshaping via rebin() (lines 221-226) is clever:

mask = sc.DataArray(monitor_mask, coords={dim: monitor.coords[dim]}).rebin(
    {dim: detector.coords[dim]}
).data != sc.scalar(0, unit=None)

The != 0 comparison handles fractional mask values from rebinning - any bin with partial masking gets fully masked (conservative approach).

Issues Found

1. Typo (Line 215)

# This can lead to masking more vents than strictly necessary

Should be "events" not "vents".

2. Potential Issue: Mask combination could be more idiomatic

Lines 237-243 manually combine masks:

mask = None
for m in masks:
    if mask is None:
        mask = m
    else:
        mask |= m

This works but could use functools.reduce(operator.or_, masks) if you wanted to be more Pythonic. However, the current approach handles the empty case more explicitly, so this is fine as-is.

Design Questions

Question 1: Line 91 wraps delta_w in a DataArray with masks, but this seems necessary only for the sum. Is there a reason not to compute the mask-aware sum directly?

Question 2: The behavior when detector has masked bins is well-documented, but it means masked detector bins still affect the normalization factor for unmasked bins. Is this the intended behavior? (The documentation suggests yes, with the warning to "only drop masks after normalization if you know what you are doing.")

Code Quality

Positive aspects:

  • Complete type annotations ✓
  • Excellent documentation with mathematical formulas ✓
  • Clear attention blocks warning about mask behavior ✓
  • Comprehensive test suite (633 lines of tests for 244 lines of code!) ✓
  • Good error messages with context ✓

Minor style observations:

  • Consistent with existing codebase style
  • Good use of helper functions for separation of concerns
  • Clear variable names

Test Coverage

The test suite is exemplary:

  • Tests for binned and histogrammed detectors
  • Aligned and unaligned binning scenarios
  • Monitor finer/coarser than detector
  • Edge cases (monitor range too narrow, boundary conditions)
  • Mask handling (detector masks, monitor masks, multiple masks, non-finite values)
  • Both normalization methods thoroughly tested

Physics/Science Correctness

The approach of:

  1. Preferring bin coordinates over event coordinates for range determination (lines 178-181)
  2. Rebinning monitor to detector for histogrammed data vs using lookup for binned data
  3. Weighting by bin widths and renormalizing

...all appears physically sound and matches the Mantid reference implementation as stated.

Recommendations

  1. Fix typo on line 215: "vents" → "events"
  2. Consider adding a sentence to the docstring explaining why _monitor_mask is set on the output (so users understand where it comes from)
  3. Optional: Add a simple usage example in the module docstring showing typical workflow

Conclusion

This is high-quality code with excellent test coverage and documentation. The recent mask-handling changes are well-thought-out improvements. The only required change is fixing the typo. The PR is ready to merge after that minor fix.

Comment on lines 237 to 244
mask = None
for m in masks:
if mask is None:
mask = m
else:
mask |= m

return mask
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could use that function. But we don't need the multi-dim handling here.

Comment on lines +223 to +186
mask = sc.DataArray(monitor_mask, coords={dim: monitor.coords[dim]}).rebin(
{dim: detector.coords[dim]}
).data != sc.scalar(0, unit=None)
Copy link
Member

Choose a reason for hiding this comment

The 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 wavelength?

Copy link
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, was this addressed somehow?


where :math:`m_i` is the monitor intensity in bin :math:`i`,
:math:`x_i` is the lower bin edge of bin :math:`i`, and
:math:`I(x_i, x_{i+1})` selects bins that are within the range of the detector.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is I(x_i, x_{i+1}) an indicator function that is 1 if the interval (x_i, x_{i+1}) is in the wavelength range of the detector and 0 otherwise?

@jl-wynen jl-wynen marked this pull request as draft November 10, 2025 10:10
@jl-wynen jl-wynen force-pushed the monitor-normalization branch from c1621cb to 99fa7f9 Compare November 10, 2025 13:56
@jl-wynen jl-wynen marked this pull request as ready for review November 10, 2025 13:56
@jl-wynen
Copy link
Member Author

jl-wynen commented Nov 10, 2025

It should be ready now. Please take a fresh look at the equations! I made some changes:

  • The monitor is no longer clipped to the detector. This must now be done by the caller. The reason is that the detector range may be too narrow for and remove important parts of the monitor histogram. (Esp. in spectroscopy)
  • The histogram norm no longer has the sum terms. They are likely needed for spectroscopy but not for diffraction. We will build something for bifrost once we know exactly what is needed.
  • The integrated norm is no longer an actual integral but a plain sum to satisfy test_independent_of_monitor_binning.

Copy link
Contributor

@jokasimr jokasimr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

Comment on lines +171 to +186
*, 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)
Copy link
Contributor

@jokasimr jokasimr Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this part when I reviewed earlier.

  1. Looks to me like we assume the detector shares a dimension with the monitor. Don't we need to check detector.bins first in that case? If detector.bins is not None then the detector dimensions probably represent the detector geometry, while the monitor always has dimension wavelength. Is that not the case?
  2. This seems to masks all regions of the monitor hat are either "not finite" or are masked. But does it mask the regions where the monitor has 0 counts? If we don't mask those regions that will divide by zero when we do the normalization.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants