essreduce 25.11.2__py3-none-any.whl → 25.11.4__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/__init__.py CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import importlib.metadata
6
6
 
7
- from . import nexus, time_of_flight, uncertainty
7
+ from . import nexus, normalization, time_of_flight, uncertainty
8
8
 
9
9
  try:
10
10
  __version__ = importlib.metadata.version("essreduce")
@@ -13,4 +13,4 @@ except importlib.metadata.PackageNotFoundError:
13
13
 
14
14
  del importlib
15
15
 
16
- __all__ = ["nexus", "time_of_flight", "uncertainty"]
16
+ __all__ = ["nexus", "normalization", "time_of_flight", "uncertainty"]
ess/reduce/live/raw.py CHANGED
@@ -21,7 +21,7 @@ options:
21
21
  from __future__ import annotations
22
22
 
23
23
  from collections.abc import Callable, Sequence
24
- from math import ceil
24
+ from math import ceil, prod
25
25
  from typing import Literal, NewType
26
26
 
27
27
  import numpy as np
@@ -138,6 +138,139 @@ class Histogrammer:
138
138
  return self._hist(replicated, coords=self._coords) / self.replicas
139
139
 
140
140
 
141
+ class LogicalView:
142
+ """
143
+ Logical view for detector data.
144
+
145
+ Implements a view by applying a user-defined transform (e.g., fold or slice
146
+ operations) optionally followed by reduction (summing) over specified dimensions.
147
+ Transformation and reduction must be specified separately for `LogicalView` to
148
+ construct a mapping from output indices to input indices. So `transform` must not
149
+ perform any reductions.
150
+
151
+ This class provides both data transformation (__call__) and index mapping
152
+ (input_indices) using the same transform, ensuring consistency for ROI filtering.
153
+ """
154
+
155
+ def __init__(
156
+ self,
157
+ transform: Callable[[sc.DataArray], sc.DataArray],
158
+ reduction_dim: str | list[str] | None = None,
159
+ input_sizes: dict[str, int] | None = None,
160
+ ):
161
+ """
162
+ Create a logical view.
163
+
164
+ Parameters
165
+ ----------
166
+ transform:
167
+ Callable that transforms input data by reshaping or selecting pixels.
168
+ Examples:
169
+ - Fold: lambda da: da.fold('x', {'x': 512, 'x_bin': 2})
170
+ - Slice: lambda da: da['z', 0] (select front layer of volume)
171
+ - Combined: lambda da: da.fold('x', {'x': 4, 'z': 8})['z', 0]
172
+ reduction_dim:
173
+ Dimension(s) to sum over after applying transform. If None or empty,
174
+ no reduction is performed (pure transform).
175
+ Example: 'x_bin' or ['x_bin', 'y_bin']
176
+ input_sizes:
177
+ Dictionary defining the input dimension sizes.
178
+ Required for input_indices().
179
+ If not provided, input_indices() will raise an error.
180
+ When used with RollingDetectorView, this is automatically
181
+ inferred from detector_number.
182
+ """
183
+ self._transform = transform
184
+ if reduction_dim is None:
185
+ self._reduction_dim = []
186
+ elif isinstance(reduction_dim, str):
187
+ self._reduction_dim = [reduction_dim]
188
+ else:
189
+ self._reduction_dim = reduction_dim
190
+ self._input_sizes = input_sizes
191
+
192
+ @property
193
+ def replicas(self) -> int:
194
+ """Number of replicas. Always 1 for LogicalView."""
195
+ return 1
196
+
197
+ def __call__(self, da: sc.DataArray) -> sc.DataArray:
198
+ """
199
+ Apply transform and optionally sum over reduction dimensions.
200
+
201
+ Parameters
202
+ ----------
203
+ da:
204
+ Data to downsample.
205
+
206
+ Returns
207
+ -------
208
+ :
209
+ Transformed (and optionally reduced) data array.
210
+ """
211
+ transformed = self._transform(da)
212
+ if self._reduction_dim:
213
+ return transformed.sum(self._reduction_dim)
214
+ return transformed
215
+
216
+ def input_indices(self) -> sc.DataArray:
217
+ """
218
+ Create index mapping for ROI filtering.
219
+
220
+ Returns a DataArray mapping output pixels to input indices (as indices into
221
+ the flattened input array). If reduction dimensions are specified, returns
222
+ binned data where each output pixel contains a list of contributing input
223
+ indices. If no reduction, returns dense indices (1:1 mapping).
224
+
225
+ Returns
226
+ -------
227
+ :
228
+ DataArray mapping output pixels to input indices.
229
+
230
+ Raises
231
+ ------
232
+ ValueError:
233
+ If input_sizes was not provided during initialization.
234
+ """
235
+ if self._input_sizes is None:
236
+ raise ValueError(
237
+ "input_sizes is required for input_indices(). "
238
+ "Provide it during LogicalView initialization."
239
+ )
240
+
241
+ # Create sequential indices (0, 1, 2, ...) and fold to input shape
242
+ total_size = prod(self._input_sizes.values())
243
+ indices = sc.arange('_temp', total_size, dtype='int64', unit=None)
244
+ indices = indices.fold(dim='_temp', sizes=self._input_sizes)
245
+
246
+ # Apply transform to get the grouping structure
247
+ transformed = self._transform(sc.DataArray(data=indices))
248
+
249
+ if not self._reduction_dim:
250
+ # No reduction: 1:1 mapping, return dense indices
251
+ return sc.DataArray(data=transformed.data)
252
+
253
+ # Flatten reduction dimensions to a single dimension.
254
+ # First transpose to make reduction dims contiguous at the end.
255
+ output_dims = [d for d in transformed.dims if d not in self._reduction_dim]
256
+ transformed = transformed.transpose(output_dims + self._reduction_dim)
257
+ transformed = transformed.flatten(dims=self._reduction_dim, to='_reduction')
258
+
259
+ # Convert dense array to binned structure where each output pixel
260
+ # contains a bin with the indices of contributing input pixels.
261
+ bin_size = transformed.sizes['_reduction']
262
+ output_shape = [transformed.sizes[d] for d in output_dims]
263
+ data_flat = transformed.data.flatten(to='_flat')
264
+ begin = sc.array(
265
+ dims=output_dims,
266
+ values=np.arange(0, data_flat.size, bin_size, dtype=np.int64).reshape(
267
+ output_shape
268
+ ),
269
+ unit=None,
270
+ )
271
+ return sc.DataArray(sc.bins(begin=begin, dim='_flat', data=data_flat))
272
+
273
+
141
274
  class Detector:
142
275
  def __init__(self, detector_number: sc.Variable):
143
276
  self._data = sc.DataArray(
@@ -220,6 +353,48 @@ class RollingDetectorView(Detector):
220
353
  self._cumulative: sc.DataArray
221
354
  self.clear_counts()
222
355
 
356
+ @staticmethod
357
+ def with_logical_view(
358
+ *,
359
+ detector_number: sc.Variable,
360
+ window: int,
361
+ transform: Callable[[sc.DataArray], sc.DataArray],
362
+ reduction_dim: str | list[str] | None = None,
363
+ ) -> RollingDetectorView:
364
+ """
365
+ Create a RollingDetectorView with a LogicalView projection.
366
+
367
+ This factory method creates a LogicalView with input_sizes
368
+ automatically inferred from detector_number.sizes.
369
+
370
+ Parameters
371
+ ----------
372
+ detector_number:
373
+ Detector number for each pixel.
374
+ window:
375
+ Size of the rolling window.
376
+ transform:
377
+ Transform function for the LogicalView.
378
+ reduction_dim:
379
+ Reduction dimension(s) for the LogicalView. If None or empty,
380
+ no reduction is performed (pure transform).
381
+
382
+ Returns
383
+ -------
384
+ :
385
+ RollingDetectorView with LogicalView projection.
386
+ """
387
+ view = LogicalView(
388
+ transform=transform,
389
+ reduction_dim=reduction_dim,
390
+ input_sizes=dict(detector_number.sizes),
391
+ )
392
+ return RollingDetectorView(
393
+ detector_number=detector_number,
394
+ window=window,
395
+ projection=view,
396
+ )
397
+
223
398
  @property
224
399
  def max_window(self) -> int:
225
400
  return self._window
@@ -249,9 +424,11 @@ class RollingDetectorView(Detector):
249
424
  def make_roi_filter(self) -> roi.ROIFilter:
250
425
  """Return a ROI filter operating via the projection plane of the view."""
251
426
  norm = 1.0
252
- if isinstance(self._projection, Histogrammer):
427
+ # Use duck typing: check if projection has input_indices method
428
+ if hasattr(self._projection, 'input_indices'):
253
429
  indices = self._projection.input_indices()
254
- norm = self._projection.replicas
430
+ # Get replicas property if it exists (Histogrammer has it, default to 1.0)
431
+ norm = getattr(self._projection, 'replicas', 1.0)
255
432
  else:
256
433
  indices = sc.ones(sizes=self.data.sizes, dtype='int32', unit=None)
257
434
  indices = sc.cumsum(indices, mode='exclusive')
@@ -292,10 +469,11 @@ class RollingDetectorView(Detector):
292
469
  if not sc.identical(det_num, self.detector_number):
293
470
  raise sc.CoordError("Mismatching detector numbers in weights.")
294
471
  weights = weights.data
295
- if isinstance(self._projection, Histogrammer):
472
+ # Use duck typing: check for apply_full method (Histogrammer)
473
+ if hasattr(self._projection, 'apply_full'):
296
474
  xs = self._projection.apply_full(weights) # Use all replicas
297
475
  elif self._projection is not None:
298
- xs = self._projection(weights)
476
+ xs = self._projection(weights) # LogicalDownsampler or callable
299
477
  else:
300
478
  xs = weights.copy()
301
479
  nonempty = xs.values[xs.values > 0]
@@ -0,0 +1,215 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
+ """Normalization routines for neutron data reduction."""
4
+
5
+ import functools
6
+
7
+ import scipp as sc
8
+
9
+ from .uncertainty import UncertaintyBroadcastMode, broadcast_uncertainties
10
+
11
+
12
+ def normalize_by_monitor_histogram(
13
+ detector: sc.DataArray,
14
+ *,
15
+ monitor: sc.DataArray,
16
+ uncertainty_broadcast_mode: UncertaintyBroadcastMode,
17
+ ) -> sc.DataArray:
18
+ """Normalize detector data by a normalized histogrammed monitor.
19
+
20
+ This normalization accounts for both the (wavelength) profile of the incident beam
21
+ and the integrated neutron flux, meaning measurement duration and source strength.
22
+
23
+ - For *event* detectors, the monitor values are mapped to the detector
24
+ using :func:`scipp.lookup`. That is, for detector event :math:`d_i`,
25
+ :math:`m_i` is the monitor bin value at the same coordinate.
26
+ - For *histogram* detectors, the monitor is rebinned using to the detector
27
+ binning using :func:`scipp.rebin`. Thus, detector value :math:`d_i` and
28
+ monitor value :math:`m_i` correspond to the same bin.
29
+
30
+ In both cases, let :math:`x_i` be the lower bound of monitor bin :math:`i`
31
+ and let :math:`\\Delta x_i = x_{i+1} - x_i` be the width of that bin.
32
+
33
+ The detector is normalized according to
34
+
35
+ .. math::
36
+
37
+ d_i^\\text{Norm} = \\frac{d_i}{m_i} \\Delta x_i
38
+
39
+ Parameters
40
+ ----------
41
+ detector:
42
+ Input detector data.
43
+ Must have a coordinate named ``monitor.dim``, that is, the single
44
+ dimension name of the **monitor**.
45
+ monitor:
46
+ A histogrammed monitor.
47
+ Must be one-dimensional and have a dimension coordinate, typically "wavelength".
48
+ uncertainty_broadcast_mode:
49
+ Choose how uncertainties of the monitor are broadcast to the sample data.
50
+
51
+ Returns
52
+ -------
53
+ :
54
+ ``detector`` normalized by ``monitor``.
55
+ If the monitor has masks or contains non-finite values, the output has a mask
56
+ called '_monitor_mask' constructed from the monitor masks and non-finite values.
57
+
58
+ See also
59
+ --------
60
+ normalize_by_monitor_integrated:
61
+ Normalize by an integrated monitor.
62
+ """
63
+ _check_monitor_range_contains_detector(monitor=monitor, detector=detector)
64
+
65
+ dim = monitor.dim
66
+
67
+ if detector.bins is None:
68
+ monitor = monitor.rebin({dim: detector.coords[dim]})
69
+ detector = _mask_detector_for_norm(detector=detector, monitor=monitor)
70
+ coord = monitor.coords[dim]
71
+ delta_w = sc.DataArray(coord[1:] - coord[:-1], masks=monitor.masks)
72
+ norm = broadcast_uncertainties(
73
+ monitor / delta_w, prototype=detector, mode=uncertainty_broadcast_mode
74
+ )
75
+
76
+ if detector.bins is None:
77
+ return detector / norm.rebin({dim: detector.coords[dim]})
78
+ return detector.bins / sc.lookup(norm, dim=dim)
79
+
80
+
81
+ def normalize_by_monitor_integrated(
82
+ detector: sc.DataArray,
83
+ *,
84
+ monitor: sc.DataArray,
85
+ uncertainty_broadcast_mode: UncertaintyBroadcastMode,
86
+ ) -> sc.DataArray:
87
+ """Normalize detector data by an integrated monitor.
88
+
89
+ This normalization accounts only for the integrated neutron flux,
90
+ meaning measurement duration and source strength.
91
+ It does *not* account for the (wavelength) profile of the incident beam.
92
+ For that, see :func:`normalize_by_monitor_histogram`.
93
+
94
+ Let :math:`d_i` be a detector event or the counts in a detector bin.
95
+ The normalized detector is
96
+
97
+ .. math::
98
+
99
+ d_i^\\text{Norm} = \\frac{d_i}{\\sum_j\\, m_j}
100
+
101
+ where :math:`m_j` is the monitor counts in bin :math:`j`.
102
+ Note that this is not a true integral but only a sum over monitor events.
103
+
104
+ The result depends on the range of the monitor but not its
105
+ binning within that range.
106
+
107
+ Parameters
108
+ ----------
109
+ detector:
110
+ Input detector data.
111
+ monitor:
112
+ A histogrammed monitor.
113
+ Must be one-dimensional and have a dimension coordinate, typically "wavelength".
114
+ uncertainty_broadcast_mode:
115
+ Choose how uncertainties of the monitor are broadcast to the sample data.
116
+
117
+ Returns
118
+ -------
119
+ :
120
+ `detector` normalized by a monitor.
121
+ If the monitor has masks or contains non-finite values, the output has a mask
122
+ called '_monitor_mask' constructed from the monitor masks and non-finite values.
123
+
124
+ See also
125
+ --------
126
+ normalize_by_monitor_histogram:
127
+ Normalize by a monitor histogram.
128
+ """
129
+ _check_monitor_range_contains_detector(monitor=monitor, detector=detector)
130
+ detector = _mask_detector_for_norm(detector=detector, monitor=monitor)
131
+ norm = monitor.nansum().data
132
+ norm = broadcast_uncertainties(
133
+ norm, prototype=detector, mode=uncertainty_broadcast_mode
134
+ )
135
+ return detector / norm
136
+
137
+
138
+ def _check_monitor_range_contains_detector(
139
+ *, monitor: sc.DataArray, detector: sc.DataArray
140
+ ) -> None:
141
+ dim = monitor.dim
142
+ if not monitor.coords.is_edges(dim):
143
+ raise sc.CoordError(
144
+ f"Monitor coordinate '{dim}' must be bin-edges to integrate the monitor."
145
+ )
146
+
147
+ # Prefer a bin coord over an event coord because this makes the behavior for binned
148
+ # and histogrammed data consistent. If we used an event coord, we might allow a
149
+ # monitor range that is less than the detector bins which is fine for the events,
150
+ # but would be wrong if the detector was subsequently histogrammed.
151
+ if (det_coord := detector.coords.get(dim)) is not None:
152
+ lo = det_coord[dim, :-1].nanmin()
153
+ hi = det_coord[dim, 1:].nanmax()
154
+ elif (det_coord := detector.bins.coords.get(dim)) is not None:
155
+ lo = det_coord.nanmin()
156
+ hi = det_coord.nanmax()
157
+ else:
158
+ raise sc.CoordError(
159
+ f"Missing '{dim}' coordinate in detector for monitor normalization."
160
+ )
161
+
162
+ if monitor.coords[dim].min() > lo or monitor.coords[dim].max() < hi:
163
+ raise ValueError(
164
+ f"Cannot normalize by monitor: The {dim} range of the monitor "
165
+ f"({monitor.coords[dim].min():c} to {monitor.coords[dim].max():c}) "
166
+ f"is smaller than the range of the detector ({lo:c} to {hi:c})."
167
+ )
168
+
169
+
170
+ def _mask_detector_for_norm(
171
+ *, detector: sc.DataArray, monitor: sc.DataArray
172
+ ) -> sc.DataArray:
173
+ """Mask the detector where the monitor is masked.
174
+
175
+ For performance, this applies the monitor mask to the detector bins.
176
+ This can lead to masking more events than strictly necessary if we
177
+ used an event mask.
178
+ """
179
+ dim = monitor.dim
180
+
181
+ if (monitor_mask := _monitor_mask(monitor)) is None:
182
+ return detector
183
+
184
+ if (detector_coord := detector.coords.get(monitor.dim)) is not None:
185
+ # Apply the mask to the bins or a dense detector.
186
+ # Use rebin to reshape the mask to the detector.
187
+ mask = sc.DataArray(monitor_mask, coords={dim: monitor.coords[dim]}).rebin(
188
+ {dim: detector_coord}
189
+ ).data != sc.scalar(0, unit=None)
190
+ return detector.assign_masks({"_monitor_mask": mask})
191
+
192
+ # else: Apply the mask to the events.
193
+ if dim not in detector.bins.coords:
194
+ raise sc.CoordError(
195
+ f"Detector must have coordinate '{dim}' to mask by monitor."
196
+ )
197
+ event_mask = sc.lookup(
198
+ sc.DataArray(monitor_mask, coords={dim: monitor.coords[dim]})
199
+ )[detector.bins.coords[dim]]
200
+ return detector.bins.assign_masks({"_monitor_mask": event_mask})
201
+
202
+
203
+ def _monitor_mask(monitor: sc.DataArray) -> sc.Variable | None:
204
+ """Mask nonfinite and zero monitor values and combine all masks."""
205
+ masks = list(monitor.masks.values())
206
+
207
+ finite = sc.isfinite(monitor.data)
208
+ nonzero = monitor.data != sc.scalar(0, unit=monitor.unit)
209
+ valid = finite & nonzero
210
+ if not valid.all():
211
+ masks.append(~valid)
212
+
213
+ if not masks:
214
+ return None
215
+ return functools.reduce(sc.logical_or, masks)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: essreduce
3
- Version: 25.11.2
3
+ Version: 25.11.4
4
4
  Summary: Common data reduction tools for the ESS facility
5
5
  Author: Scipp contributors
6
6
  License-Expression: BSD-3-Clause
@@ -1,5 +1,6 @@
1
- ess/reduce/__init__.py,sha256=o1pWRP9YGwTukM_k-qlG6KcoXOpMb0PDVH59vod12lw,419
1
+ ess/reduce/__init__.py,sha256=9iqQ57K3stwyujDzOk30hj7WqZt1Ycnb9AVDDDmk3K0,451
2
2
  ess/reduce/logging.py,sha256=6n8Czq4LZ3OK9ENlKsWSI1M3KvKv6_HSoUiV4__IUlU,357
3
+ ess/reduce/normalization.py,sha256=B4O5W3CV_ti-zeU7tyQEAXk5pCUebZ0BG30YN2I3TyY,7844
3
4
  ess/reduce/parameter.py,sha256=4sCfoKOI2HuO_Q7JLH_jAXnEOFANSn5P3NdaOBzhJxc,4635
4
5
  ess/reduce/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
6
  ess/reduce/streaming.py,sha256=zbqxQz5dASDq4ZVyx-TdbapBXMyBttImCYz_6WOj4pg,17978
@@ -9,7 +10,7 @@ ess/reduce/workflow.py,sha256=738-lcdgsORYfQ4A0UTk2IgnbVxC3jBdpscpaOFIpdc,3114
9
10
  ess/reduce/data/__init__.py,sha256=uDtqkmKA_Zwtj6II25zntz9T812XhdCn3tktYev4uyY,486
10
11
  ess/reduce/data/_registry.py,sha256=50qY36y5gd2i3JP0Ks6bXApGcW6l70qA6riO0m9Bz4o,11475
11
12
  ess/reduce/live/__init__.py,sha256=jPQVhihRVNtEDrE20PoKkclKV2aBF1lS7cCHootgFgI,204
12
- ess/reduce/live/raw.py,sha256=d86s5fBjdf6tI_FWNMG4ZG67GCY2JADOobrinncNIjE,24367
13
+ ess/reduce/live/raw.py,sha256=CkPqp4VMNvj0IcFPp1J0n7sVt5PNKdIXnDlALCg9W_Q,31031
13
14
  ess/reduce/live/roi.py,sha256=Hs-pW98k41WU6Kl3UQ41kQawk80c2QNOQ_WNctLzDPE,3795
14
15
  ess/reduce/live/workflow.py,sha256=bsbwvTqPhRO6mC__3b7MgU7DWwAnOvGvG-t2n22EKq8,4285
15
16
  ess/reduce/nexus/__init__.py,sha256=xXc982vZqRba4jR4z5hA2iim17Z7niw4KlS1aLFbn1Q,1107
@@ -40,9 +41,9 @@ ess/reduce/widgets/_spinner.py,sha256=2VY4Fhfa7HMXox2O7UbofcdKsYG-AJGrsgGJB85nDX
40
41
  ess/reduce/widgets/_string_widget.py,sha256=iPAdfANyXHf-nkfhgkyH6gQDklia0LebLTmwi3m-iYQ,1482
41
42
  ess/reduce/widgets/_switchable_widget.py,sha256=fjKz99SKLhIF1BLgGVBSKKn3Lu_jYBwDYGeAjbJY3Q8,2390
42
43
  ess/reduce/widgets/_vector_widget.py,sha256=aTaBqCFHZQhrIoX6-sSqFWCPePEW8HQt5kUio8jP1t8,1203
43
- essreduce-25.11.2.dist-info/licenses/LICENSE,sha256=nVEiume4Qj6jMYfSRjHTM2jtJ4FGu0g-5Sdh7osfEYw,1553
44
- essreduce-25.11.2.dist-info/METADATA,sha256=BiPKZU7f3M7bof_N1dQWwIfG8TAEHATY18zU6s4ihxk,1937
45
- essreduce-25.11.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
- essreduce-25.11.2.dist-info/entry_points.txt,sha256=PMZOIYzCifHMTe4pK3HbhxUwxjFaZizYlLD0td4Isb0,66
47
- essreduce-25.11.2.dist-info/top_level.txt,sha256=0JxTCgMKPLKtp14wb1-RKisQPQWX7i96innZNvHBr-s,4
48
- essreduce-25.11.2.dist-info/RECORD,,
44
+ essreduce-25.11.4.dist-info/licenses/LICENSE,sha256=nVEiume4Qj6jMYfSRjHTM2jtJ4FGu0g-5Sdh7osfEYw,1553
45
+ essreduce-25.11.4.dist-info/METADATA,sha256=Foul2luwyG1rhulA2Q5KlXbxtrYFGlp99ApSkGjAKOE,1937
46
+ essreduce-25.11.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
47
+ essreduce-25.11.4.dist-info/entry_points.txt,sha256=PMZOIYzCifHMTe4pK3HbhxUwxjFaZizYlLD0td4Isb0,66
48
+ essreduce-25.11.4.dist-info/top_level.txt,sha256=0JxTCgMKPLKtp14wb1-RKisQPQWX7i96innZNvHBr-s,4
49
+ essreduce-25.11.4.dist-info/RECORD,,