essreduce 25.11.3__tar.gz → 25.11.4__tar.gz

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.
Files changed (149) hide show
  1. {essreduce-25.11.3 → essreduce-25.11.4}/PKG-INFO +1 -1
  2. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/live/raw.py +183 -5
  3. {essreduce-25.11.3 → essreduce-25.11.4}/src/essreduce.egg-info/PKG-INFO +1 -1
  4. {essreduce-25.11.3 → essreduce-25.11.4}/tests/live/raw_test.py +374 -0
  5. {essreduce-25.11.3 → essreduce-25.11.4}/.copier-answers.ess.yml +0 -0
  6. {essreduce-25.11.3 → essreduce-25.11.4}/.copier-answers.yml +0 -0
  7. {essreduce-25.11.3 → essreduce-25.11.4}/.github/ISSUE_TEMPLATE/high-level-requirement.yml +0 -0
  8. {essreduce-25.11.3 → essreduce-25.11.4}/.github/dependabot.yml +0 -0
  9. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/ci.yml +0 -0
  10. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/docs.yml +0 -0
  11. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/nightly_at_main.yml +0 -0
  12. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/nightly_at_main_lower_bound.yml +0 -0
  13. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/nightly_at_release.yml +0 -0
  14. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/python-version-ci +0 -0
  15. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/release.yml +0 -0
  16. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/test.yml +0 -0
  17. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/unpinned.yml +0 -0
  18. {essreduce-25.11.3 → essreduce-25.11.4}/.github/workflows/weekly_windows_macos.yml +0 -0
  19. {essreduce-25.11.3 → essreduce-25.11.4}/.gitignore +0 -0
  20. {essreduce-25.11.3 → essreduce-25.11.4}/.pre-commit-config.yaml +0 -0
  21. {essreduce-25.11.3 → essreduce-25.11.4}/.python-version +0 -0
  22. {essreduce-25.11.3 → essreduce-25.11.4}/CODE_OF_CONDUCT.md +0 -0
  23. {essreduce-25.11.3 → essreduce-25.11.4}/CONTRIBUTING.md +0 -0
  24. {essreduce-25.11.3 → essreduce-25.11.4}/LICENSE +0 -0
  25. {essreduce-25.11.3 → essreduce-25.11.4}/MANIFEST.in +0 -0
  26. {essreduce-25.11.3 → essreduce-25.11.4}/README.md +0 -0
  27. {essreduce-25.11.3 → essreduce-25.11.4}/docs/_static/anaconda-icon.js +0 -0
  28. {essreduce-25.11.3 → essreduce-25.11.4}/docs/_static/favicon.svg +0 -0
  29. {essreduce-25.11.3 → essreduce-25.11.4}/docs/_static/logo-dark.svg +0 -0
  30. {essreduce-25.11.3 → essreduce-25.11.4}/docs/_static/logo.svg +0 -0
  31. {essreduce-25.11.3 → essreduce-25.11.4}/docs/_templates/class-template.rst +0 -0
  32. {essreduce-25.11.3 → essreduce-25.11.4}/docs/_templates/doc_version.html +0 -0
  33. {essreduce-25.11.3 → essreduce-25.11.4}/docs/_templates/module-template.rst +0 -0
  34. {essreduce-25.11.3 → essreduce-25.11.4}/docs/about/index.md +0 -0
  35. {essreduce-25.11.3 → essreduce-25.11.4}/docs/api-reference/index.md +0 -0
  36. {essreduce-25.11.3 → essreduce-25.11.4}/docs/conf.py +0 -0
  37. {essreduce-25.11.3 → essreduce-25.11.4}/docs/developer/coding-conventions.md +0 -0
  38. {essreduce-25.11.3 → essreduce-25.11.4}/docs/developer/dependency-management.md +0 -0
  39. {essreduce-25.11.3 → essreduce-25.11.4}/docs/developer/getting-started.md +0 -0
  40. {essreduce-25.11.3 → essreduce-25.11.4}/docs/developer/gui.ipynb +0 -0
  41. {essreduce-25.11.3 → essreduce-25.11.4}/docs/developer/index.md +0 -0
  42. {essreduce-25.11.3 → essreduce-25.11.4}/docs/index.md +0 -0
  43. {essreduce-25.11.3 → essreduce-25.11.4}/docs/user-guide/index.md +0 -0
  44. {essreduce-25.11.3 → essreduce-25.11.4}/docs/user-guide/installation.md +0 -0
  45. {essreduce-25.11.3 → essreduce-25.11.4}/docs/user-guide/reduction-workflow-guidelines.md +0 -0
  46. {essreduce-25.11.3 → essreduce-25.11.4}/docs/user-guide/tof/dream.ipynb +0 -0
  47. {essreduce-25.11.3 → essreduce-25.11.4}/docs/user-guide/tof/frame-unwrapping.ipynb +0 -0
  48. {essreduce-25.11.3 → essreduce-25.11.4}/docs/user-guide/tof/index.md +0 -0
  49. {essreduce-25.11.3 → essreduce-25.11.4}/docs/user-guide/tof/wfm.ipynb +0 -0
  50. {essreduce-25.11.3 → essreduce-25.11.4}/docs/user-guide/widget.md +0 -0
  51. {essreduce-25.11.3 → essreduce-25.11.4}/pyproject.toml +0 -0
  52. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/base.in +0 -0
  53. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/base.txt +0 -0
  54. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/basetest.in +0 -0
  55. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/basetest.txt +0 -0
  56. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/ci.in +0 -0
  57. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/ci.txt +0 -0
  58. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/dev.in +0 -0
  59. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/dev.txt +0 -0
  60. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/docs.in +0 -0
  61. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/docs.txt +0 -0
  62. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/make_base.py +0 -0
  63. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/mypy.in +0 -0
  64. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/mypy.txt +0 -0
  65. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/nightly.in +0 -0
  66. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/nightly.txt +0 -0
  67. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/static.in +0 -0
  68. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/static.txt +0 -0
  69. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/test.in +0 -0
  70. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/test.txt +0 -0
  71. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/wheels.in +0 -0
  72. {essreduce-25.11.3 → essreduce-25.11.4}/requirements/wheels.txt +0 -0
  73. {essreduce-25.11.3 → essreduce-25.11.4}/resources/logo.svg +0 -0
  74. {essreduce-25.11.3 → essreduce-25.11.4}/setup.cfg +0 -0
  75. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/__init__.py +0 -0
  76. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/data/__init__.py +0 -0
  77. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/data/_registry.py +0 -0
  78. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/live/__init__.py +0 -0
  79. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/live/roi.py +0 -0
  80. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/live/workflow.py +0 -0
  81. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/logging.py +0 -0
  82. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/nexus/__init__.py +0 -0
  83. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/nexus/_nexus_loader.py +0 -0
  84. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/nexus/json_generator.py +0 -0
  85. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/nexus/json_nexus.py +0 -0
  86. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/nexus/types.py +0 -0
  87. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/nexus/workflow.py +0 -0
  88. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/normalization.py +0 -0
  89. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/parameter.py +0 -0
  90. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/py.typed +0 -0
  91. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/scripts/grow_nexus.py +0 -0
  92. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/streaming.py +0 -0
  93. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/__init__.py +0 -0
  94. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/eto_to_tof.py +0 -0
  95. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/fakes.py +0 -0
  96. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/interpolator_numba.py +0 -0
  97. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/interpolator_scipy.py +0 -0
  98. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/lut.py +0 -0
  99. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/resample.py +0 -0
  100. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/types.py +0 -0
  101. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/time_of_flight/workflow.py +0 -0
  102. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/ui.py +0 -0
  103. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/uncertainty.py +0 -0
  104. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/__init__.py +0 -0
  105. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_base.py +0 -0
  106. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_binedges_widget.py +0 -0
  107. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_bounds_widget.py +0 -0
  108. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_config.py +0 -0
  109. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_filename_widget.py +0 -0
  110. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_linspace_widget.py +0 -0
  111. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_optional_widget.py +0 -0
  112. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_spinner.py +0 -0
  113. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_string_widget.py +0 -0
  114. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_switchable_widget.py +0 -0
  115. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/widgets/_vector_widget.py +0 -0
  116. {essreduce-25.11.3 → essreduce-25.11.4}/src/ess/reduce/workflow.py +0 -0
  117. {essreduce-25.11.3 → essreduce-25.11.4}/src/essreduce.egg-info/SOURCES.txt +0 -0
  118. {essreduce-25.11.3 → essreduce-25.11.4}/src/essreduce.egg-info/dependency_links.txt +0 -0
  119. {essreduce-25.11.3 → essreduce-25.11.4}/src/essreduce.egg-info/entry_points.txt +0 -0
  120. {essreduce-25.11.3 → essreduce-25.11.4}/src/essreduce.egg-info/requires.txt +0 -0
  121. {essreduce-25.11.3 → essreduce-25.11.4}/src/essreduce.egg-info/top_level.txt +0 -0
  122. {essreduce-25.11.3 → essreduce-25.11.4}/tests/accumulators_test.py +0 -0
  123. {essreduce-25.11.3 → essreduce-25.11.4}/tests/conftest.py +0 -0
  124. {essreduce-25.11.3 → essreduce-25.11.4}/tests/live/roi_test.py +0 -0
  125. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_generator_test.py +0 -0
  126. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_nexus_examples/array_dataset.json +0 -0
  127. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_nexus_examples/dataset.json +0 -0
  128. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_nexus_examples/detector.json +0 -0
  129. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_nexus_examples/entry.json +0 -0
  130. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_nexus_examples/event_data.json +0 -0
  131. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_nexus_examples/instrument.json +0 -0
  132. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_nexus_examples/log.json +0 -0
  133. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/json_nexus_test.py +0 -0
  134. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/nexus_loader_test.py +0 -0
  135. {essreduce-25.11.3 → essreduce-25.11.4}/tests/nexus/workflow_test.py +0 -0
  136. {essreduce-25.11.3 → essreduce-25.11.4}/tests/normalization_test.py +0 -0
  137. {essreduce-25.11.3 → essreduce-25.11.4}/tests/package_test.py +0 -0
  138. {essreduce-25.11.3 → essreduce-25.11.4}/tests/scripts/test_grow_nexus.py +0 -0
  139. {essreduce-25.11.3 → essreduce-25.11.4}/tests/streaming_test.py +0 -0
  140. {essreduce-25.11.3 → essreduce-25.11.4}/tests/time_of_flight/interpolator_test.py +0 -0
  141. {essreduce-25.11.3 → essreduce-25.11.4}/tests/time_of_flight/lut_test.py +0 -0
  142. {essreduce-25.11.3 → essreduce-25.11.4}/tests/time_of_flight/resample_tests.py +0 -0
  143. {essreduce-25.11.3 → essreduce-25.11.4}/tests/time_of_flight/unwrap_test.py +0 -0
  144. {essreduce-25.11.3 → essreduce-25.11.4}/tests/time_of_flight/wfm_test.py +0 -0
  145. {essreduce-25.11.3 → essreduce-25.11.4}/tests/time_of_flight/workflow_test.py +0 -0
  146. {essreduce-25.11.3 → essreduce-25.11.4}/tests/uncertainty_test.py +0 -0
  147. {essreduce-25.11.3 → essreduce-25.11.4}/tests/widget_test.py +0 -0
  148. {essreduce-25.11.3 → essreduce-25.11.4}/tools/shrink_nexus.py +0 -0
  149. {essreduce-25.11.3 → essreduce-25.11.4}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: essreduce
3
- Version: 25.11.3
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
@@ -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]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: essreduce
3
- Version: 25.11.3
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
@@ -547,3 +547,377 @@ def test_transform_weights_raises_given_DataArray_with_bad_det_num() -> None:
547
547
  )
548
548
  with pytest.raises(sc.CoordError):
549
549
  view.transform_weights(weights)
550
+
551
+
552
+ class TestLogicalView:
553
+ """Tests for LogicalView class."""
554
+
555
+ def test_single_dim_downsampling(self) -> None:
556
+ """Test basic 1D downsampling with transform + reduction."""
557
+
558
+ # Transform: fold into 4 groups of 2
559
+ def transform(da: sc.DataArray) -> sc.DataArray:
560
+ return da.fold(
561
+ dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}
562
+ )
563
+
564
+ view = raw.LogicalView(
565
+ transform=transform,
566
+ reduction_dim='x_bin',
567
+ )
568
+
569
+ # Create test data: each pixel has value equal to its index
570
+ data = sc.DataArray(
571
+ data=sc.arange('x_pixel_offset', 8, dtype='float64', unit='counts')
572
+ )
573
+
574
+ # Apply downsampling
575
+ result = view(data)
576
+
577
+ # Should sum pairs: [0+1, 2+3, 4+5, 6+7] = [1, 5, 9, 13]
578
+ expected = sc.array(
579
+ dims=['x_pixel_offset'],
580
+ values=[1.0, 5.0, 9.0, 13.0],
581
+ unit='counts',
582
+ )
583
+ assert sc.allclose(result.data, expected)
584
+ assert result.sizes == {'x_pixel_offset': 4}
585
+
586
+ def test_multi_dim_downsampling(self) -> None:
587
+ """Test 2D downsampling similar to _resize_image example."""
588
+
589
+ # Transform: fold both dimensions for 8x8 -> 4x4 (2x2 binning in each dimension)
590
+ def transform(da: sc.DataArray) -> sc.DataArray:
591
+ da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2})
592
+ da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 4, 'y_bin': 2})
593
+ return da
594
+
595
+ view = raw.LogicalView(
596
+ transform=transform,
597
+ reduction_dim=['x_bin', 'y_bin'],
598
+ )
599
+
600
+ # Create test data: constant value of 1 everywhere
601
+ data = sc.DataArray(
602
+ data=sc.ones(
603
+ sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}, unit='counts'
604
+ )
605
+ )
606
+
607
+ # Apply downsampling
608
+ result = view(data)
609
+
610
+ # Each output pixel should be sum of 2x2=4 input pixels
611
+ expected = sc.full(
612
+ dims=['x_pixel_offset', 'y_pixel_offset'],
613
+ shape=[4, 4],
614
+ value=4.0,
615
+ unit='counts',
616
+ )
617
+ assert sc.allclose(result.data, expected)
618
+ assert result.sizes == {'x_pixel_offset': 4, 'y_pixel_offset': 4}
619
+
620
+ def test_input_indices_single_dim(self) -> None:
621
+ """Test that input_indices creates correct binned mapping for 1D."""
622
+
623
+ def transform(da: sc.DataArray) -> sc.DataArray:
624
+ return da.fold(
625
+ dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}
626
+ )
627
+
628
+ view = raw.LogicalView(
629
+ transform=transform,
630
+ reduction_dim='x_bin',
631
+ input_sizes={'x_pixel_offset': 8},
632
+ )
633
+
634
+ # Get index mapping
635
+ indices = view.input_indices()
636
+
637
+ # Should be binned data with 4 bins, each containing 2 indices
638
+ assert indices.sizes == {'x_pixel_offset': 4}
639
+ assert indices.bins is not None
640
+
641
+ # Check each bin contains the correct indices
642
+ # Bin 0: [0, 1], Bin 1: [2, 3], Bin 2: [4, 5], Bin 3: [6, 7]
643
+ # Extract all bin contents using the bins accessor
644
+ bin_sizes = indices.bins.size()
645
+ assert all(bin_sizes.values == 2) # Each bin should have 2 indices
646
+
647
+ # Check total count
648
+ assert indices.bins.size().sum().value == 8
649
+
650
+ def test_input_indices_multi_dim(self) -> None:
651
+ """Test that input_indices creates correct binned mapping for 2D."""
652
+
653
+ def transform(da: sc.DataArray) -> sc.DataArray:
654
+ da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 2, 'x_bin': 2})
655
+ da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 2, 'y_bin': 2})
656
+ return da
657
+
658
+ view = raw.LogicalView(
659
+ transform=transform,
660
+ reduction_dim=['x_bin', 'y_bin'],
661
+ input_sizes={'x_pixel_offset': 4, 'y_pixel_offset': 4},
662
+ )
663
+
664
+ # Get index mapping
665
+ indices = view.input_indices()
666
+
667
+ # Should be binned data with 2x2 output bins
668
+ assert indices.sizes == {'x_pixel_offset': 2, 'y_pixel_offset': 2}
669
+ assert indices.bins is not None
670
+
671
+ # Each bin should contain 2x2=4 indices from the flattened input
672
+ bin_sizes = indices.bins.size()
673
+ assert all(bin_sizes.values.ravel() == 4) # Each bin should have 4 indices
674
+
675
+ # Check total count: 4x4 input pixels -> 2x2 output bins
676
+ assert indices.bins.size().sum().value == 16
677
+
678
+ def test_with_varying_input_values(self) -> None:
679
+ """Test that downsampling correctly sums varying input values."""
680
+
681
+ def transform(da: sc.DataArray) -> sc.DataArray:
682
+ return da.fold(
683
+ dim='x_pixel_offset', sizes={'x_pixel_offset': 3, 'x_bin': 2}
684
+ )
685
+
686
+ view = raw.LogicalView(
687
+ transform=transform,
688
+ reduction_dim='x_bin',
689
+ )
690
+
691
+ # Create test data with specific values: [10, 20, 30, 40, 50, 60]
692
+ data = sc.DataArray(
693
+ data=sc.array(
694
+ dims=['x_pixel_offset'],
695
+ values=[10.0, 20.0, 30.0, 40.0, 50.0, 60.0],
696
+ unit='counts',
697
+ )
698
+ )
699
+
700
+ result = view(data)
701
+
702
+ # Should sum pairs: [10+20, 30+40, 50+60] = [30, 70, 110]
703
+ expected = sc.array(
704
+ dims=['x_pixel_offset'],
705
+ values=[30.0, 70.0, 110.0],
706
+ unit='counts',
707
+ )
708
+ assert sc.allclose(result.data, expected)
709
+
710
+ def test_transform_without_reduction_slicing(self) -> None:
711
+ """Test transform without reduction (slicing to select front layer)."""
712
+
713
+ # Transform: fold to 3D volume, then slice front layer
714
+ def transform(da: sc.DataArray) -> sc.DataArray:
715
+ return da.fold(dim='voxel', sizes={'x': 2, 'y': 2, 'z': 3})['z', 0]
716
+
717
+ view = raw.LogicalView(transform=transform)
718
+
719
+ # Create test data: each voxel has value equal to its index
720
+ data = sc.DataArray(data=sc.arange('voxel', 12, dtype='float64', unit='counts'))
721
+
722
+ result = view(data)
723
+
724
+ # fold orders: z is innermost, so z=0 gives every 3rd element starting at 0
725
+ # indices: 0, 3, 6, 9
726
+ assert result.sizes == {'x': 2, 'y': 2}
727
+ expected = sc.array(
728
+ dims=['x', 'y'],
729
+ values=[[0.0, 3.0], [6.0, 9.0]],
730
+ unit='counts',
731
+ )
732
+ assert sc.allclose(result.data, expected)
733
+
734
+ def test_transform_without_reduction_reshape(self) -> None:
735
+ """Test transform without reduction (pure reshape)."""
736
+
737
+ # Transform: just fold without any reduction
738
+ def transform(da: sc.DataArray) -> sc.DataArray:
739
+ return da.fold(dim='pixel', sizes={'x': 3, 'y': 4})
740
+
741
+ view = raw.LogicalView(transform=transform)
742
+
743
+ data = sc.DataArray(data=sc.arange('pixel', 12, dtype='float64', unit='counts'))
744
+
745
+ result = view(data)
746
+
747
+ # Should just reshape, no reduction
748
+ assert result.sizes == {'x': 3, 'y': 4}
749
+ assert result.data.sum().value == 66.0 # Sum of 0..11
750
+
751
+ def test_input_indices_without_reduction(self) -> None:
752
+ """Test that input_indices returns dense indices when no reduction."""
753
+
754
+ def transform(da: sc.DataArray) -> sc.DataArray:
755
+ return da.fold(dim='voxel', sizes={'x': 2, 'y': 2, 'z': 3})['z', 0]
756
+
757
+ view = raw.LogicalView(
758
+ transform=transform,
759
+ input_sizes={'voxel': 12},
760
+ )
761
+
762
+ indices = view.input_indices()
763
+
764
+ # Should be dense (not binned) - 1:1 mapping
765
+ assert indices.bins is None
766
+ assert indices.sizes == {'x': 2, 'y': 2}
767
+
768
+ # Indices should correspond to front layer of folded volume
769
+ # fold orders: z is innermost, so z=0 gives every 3rd index starting at 0
770
+ expected = sc.array(dims=['x', 'y'], values=[[0, 3], [6, 9]], unit=None)
771
+ assert sc.identical(indices.data, expected)
772
+
773
+ def test_input_indices_without_reduction_preserves_total_count(self) -> None:
774
+ """Test that non-reducing input_indices has correct number of indices."""
775
+
776
+ def transform(da: sc.DataArray) -> sc.DataArray:
777
+ return da.fold(dim='pixel', sizes={'x': 4, 'y': 5})
778
+
779
+ view = raw.LogicalView(
780
+ transform=transform,
781
+ input_sizes={'pixel': 20},
782
+ )
783
+
784
+ indices = view.input_indices()
785
+
786
+ # Dense indices should have same total size as output shape
787
+ assert indices.sizes == {'x': 4, 'y': 5}
788
+ assert indices.data.size == 20
789
+
790
+
791
+ class TestRollingDetectorViewWithLogicalView:
792
+ """Tests for RollingDetectorView integration with LogicalView."""
793
+
794
+ def test_as_projection(self) -> None:
795
+ """Test that RollingDetectorView works with LogicalView as projection."""
796
+ # Create a 1D detector with 8 pixels
797
+ detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None)
798
+
799
+ # Define downsampling transform: 8 -> 4 pixels (2x binning)
800
+ def transform(da: sc.DataArray) -> sc.DataArray:
801
+ return da.fold(
802
+ dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}
803
+ )
804
+
805
+ # Create RollingDetectorView with LogicalView using factory method
806
+ view = raw.RollingDetectorView.with_logical_view(
807
+ detector_number=detector_number,
808
+ window=2,
809
+ transform=transform,
810
+ reduction_dim='x_bin',
811
+ )
812
+
813
+ # Add some counts: pixels 1, 2, 3, 4 -> downsampled bins [0, 1]
814
+ view.add_counts([1, 2, 3, 4])
815
+ result = view.get()
816
+
817
+ # After downsampling: [1+2, 3+4] = [2, 2] for first two bins
818
+ assert result.sizes == {'x_pixel_offset': 4}
819
+ assert result['x_pixel_offset', 0].value == 2
820
+ assert result['x_pixel_offset', 1].value == 2
821
+ assert result['x_pixel_offset', 2].value == 0
822
+ assert result['x_pixel_offset', 3].value == 0
823
+
824
+ def test_make_roi_filter(self) -> None:
825
+ """Test that make_roi_filter() works with LogicalView."""
826
+ detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None)
827
+
828
+ def transform(da: sc.DataArray) -> sc.DataArray:
829
+ return da.fold(
830
+ dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}
831
+ )
832
+
833
+ view = raw.RollingDetectorView.with_logical_view(
834
+ detector_number=detector_number,
835
+ window=1,
836
+ transform=transform,
837
+ reduction_dim='x_bin',
838
+ )
839
+
840
+ # Should not raise - LogicalView has input_indices()
841
+ roi_filter = view.make_roi_filter()
842
+
843
+ # The indices should be binned data (check via private attribute for now)
844
+ assert roi_filter._indices.bins is not None
845
+ assert roi_filter._indices.sizes == {'x_pixel_offset': 4}
846
+ # Each bin should contain 2 indices
847
+ assert all(roi_filter._indices.bins.size().values == 2)
848
+
849
+ def test_transform_weights(self) -> None:
850
+ """Test that transform_weights() works with LogicalView."""
851
+ detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None)
852
+
853
+ def transform(da: sc.DataArray) -> sc.DataArray:
854
+ return da.fold(
855
+ dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}
856
+ )
857
+
858
+ view = raw.RollingDetectorView.with_logical_view(
859
+ detector_number=detector_number,
860
+ window=1,
861
+ transform=transform,
862
+ reduction_dim='x_bin',
863
+ )
864
+
865
+ # Create weights: all pixels have weight 1.0
866
+ weights = sc.ones(sizes={'x_pixel_offset': 8}, dtype='float32', unit='')
867
+
868
+ # Transform weights through the downsampler
869
+ transformed = view.transform_weights(weights)
870
+
871
+ # After downsampling: each output bin sums 2 input weights = 2.0
872
+ assert transformed.sizes == {'x_pixel_offset': 4}
873
+ expected = sc.full(
874
+ dims=['x_pixel_offset'], shape=[4], value=2.0, dtype='float32', unit=''
875
+ )
876
+ assert sc.allclose(transformed.data, expected)
877
+
878
+ def test_with_non_reducing_view(self) -> None:
879
+ """Test RollingDetectorView with LogicalView without reduction (slicing)."""
880
+ # 12 voxels that will be folded into 2x2x3 and sliced to front layer
881
+ detector_number = sc.arange('voxel', 1, 13, unit=None)
882
+
883
+ def transform(da: sc.DataArray) -> sc.DataArray:
884
+ return da.fold(dim='voxel', sizes={'x': 2, 'y': 2, 'z': 3})['z', 0]
885
+
886
+ view = raw.RollingDetectorView.with_logical_view(
887
+ detector_number=detector_number,
888
+ window=2,
889
+ transform=transform,
890
+ # No reduction_dim - pure transform
891
+ )
892
+
893
+ # Add counts for detector_numbers that map to front layer (z=0)
894
+ # z is innermost, so front layer indices are 0, 3, 6, 9 (every 3rd)
895
+ # detector_numbers are 1-indexed, so front layer det_nums are 1, 4, 7, 10
896
+ view.add_counts([1, 4, 7, 10])
897
+ result = view.get()
898
+
899
+ assert result.sizes == {'x': 2, 'y': 2}
900
+ # Each front-layer pixel gets one count
901
+ expected = sc.array(
902
+ dims=['x', 'y'], values=[[1, 1], [1, 1]], dtype='int32', unit='counts'
903
+ )
904
+ assert sc.identical(result.data, expected)
905
+
906
+ def test_make_roi_filter_with_non_reducing_view(self) -> None:
907
+ """Test make_roi_filter with non-reducing LogicalView returns dense indices."""
908
+ detector_number = sc.arange('voxel', 1, 13, unit=None)
909
+
910
+ def transform(da: sc.DataArray) -> sc.DataArray:
911
+ return da.fold(dim='voxel', sizes={'x': 2, 'y': 2, 'z': 3})['z', 0]
912
+
913
+ view = raw.RollingDetectorView.with_logical_view(
914
+ detector_number=detector_number,
915
+ window=1,
916
+ transform=transform,
917
+ )
918
+
919
+ roi_filter = view.make_roi_filter()
920
+
921
+ # Indices should be dense (not binned) for non-reducing view
922
+ assert roi_filter._indices.bins is None
923
+ assert roi_filter._indices.sizes == {'x': 2, 'y': 2}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes